Skip to content

feat(launchpad): dedupe via an injectable second judge (#118 STEP 7) - #267

Merged
tucktuck101 merged 4 commits into
feat/review-agent-adjudication-escalatefrom
feat/review-agent-adjudication-dedupe
Aug 23, 2026
Merged

feat(launchpad): dedupe via an injectable second judge (#118 STEP 7)#267
tucktuck101 merged 4 commits into
feat/review-agent-adjudication-escalatefrom
feat/review-agent-adjudication-dedupe

Conversation

@serina-mcfall

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

Copy link
Copy Markdown

Summary

Implements STEP 7 of launchpad/plans/2026-08-13-issue-118-adjudication.md: dedupe, discoverable from both ends. Findings describing the same defect in different words are grouped, with every duplicate also naming its survivor via duplicate_of, and nothing is dropped. Stacked on the not-yet-merged STEP 6 branch (#266).

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

Extend launchpad/review-agent/run_adjudication.py with cross-dimension dedupe, via a second injectable judge called once after every verdict is final.

Impacted components

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

Approach and rejected alternatives

The plan specifies the outcome shape but not the detection mechanism, so this is a decision made and recorded rather than inherited. The existing per-finding Judge protocol has no visibility into other findings, so it structurally cannot detect a cross-finding duplicate.

Rejected: reshaping Judge to receive all findings. That ripples through STEPs 3/4/6's existing code and tests for a conceptually separate question. Instead a second callable, dedupe_judge(adjudicated_findings, input_document) -> list[list[finding_id]], is called exactly once after severity re-rating is final. It defaults to stub_dedupe_judge, which finds nothing — the conservative direction, mirroring ADJUDICATION.md's UNPROVEN-over-REFUTED default.

Nothing is dropped: a duplicate still receives its own verdict and stays in reports[].findings. The survivor is chosen by a plain sort key — highest adjudicated severity, then CONFIRMED > UNPROVEN > REFUTED, then lowest finding_id — so "the best one" is a rule rather than a judgement.

Verification

Command run:

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

Raw output:

Ran 63 tests in 0.247s
OK
Ran 17 tests in 0.002s
OK
......................................................................................................................................................................................................................................
----------------------------------------------------------------------
Ran 230 tests in 0.873s

OK

The 63 is test_run_adjudication alone, not the whole review-agent suite — the 230 run is the directory. Both are after merging #266, which carries #264's stages fix and #261/#263's evidence guard up to this branch.

Totality of the survivor sort key, checked because a tie at all three levels would make the byte-identical-output guarantee order-dependent:

_survivor_sort_key -> (severity_rank, verdict_rank, finding_id)
candidate_ids built with `fid not in candidate_ids`, every id `in findings_by_id`
  -> findings_by_id is keyed by finding_id, populated only if isinstance(fid, str)
  -> every candidate is a DISTINCT string id
  -> third level is a strict tiebreaker; min() cannot depend on iteration order
  -> the "" fallback is unreachable from adjudicate()

review.py:32  SEVERITY_ORDER = {"Blocker": 0, "High": 1, "Medium": 2, "Low": 3}
  -> min() over the key picks the HIGHEST severity, as the docstring claims
  • 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 has ever deduped anything. stub_dedupe_judge returns [], so every group in every test comes from a hand-written injected judge. Whether a real model groups sanely — or groups aggressively enough to merge two genuinely distinct defects — is entirely unmeasured. That is the risk this step's conservative default is chosen against, not evidence against it.

Byte-identical output across two runs was reasoned, not re-executed on this branch. The premise is now verified (the sort key is total, duplicates is sorted(), groups append in the judge's order), and a committed test asserts it — but the independent re-measurement was not repeated after the #266 merge.

Two adversarial dedupe cases were confirmed by inspection, not driven: a judge grouping a finding with itself, and one returning the same pair twice. Both follow from the len(candidate_ids) < 2 gate and the claimed set.

except Exception in the dedupe fail-closed path does not catch BaseException. A dedupe judge raising SystemExit or KeyboardInterrupt aborts the run rather than failing closed to []. Same convention _run_judge_safely already uses, and arguably correct for SystemExit, but it is not total coverage.

"All nine plan OPEN items stayed open" was not audited item by item. The OPEN section was read in full for the notes question only.

Nothing reads any of this. #119 is not built, so duplicate_groups and duplicate_of are producer-with-no-consumer — deliberate, and named field by field in the plan's OPEN section.

Security implications

Dedupe is the one step that can make a finding less visible, so the failure mode is a real defect hidden behind a survivor. Three properties limit that: nothing is removed (a duplicate keeps its own verdict and its place in reports[].findings), the grouping is discoverable from both directions so a reader cannot see a group without seeing its members, and survivor choice is a deterministic sort rather than a judgement.

A badly-behaved dedupe judge fails closed: single-element groups are dropped, absent finding_ids are filtered, a finding claimed by two groups is resolved by first claim, a non-list return becomes [], and an exception becomes []. None of those reach duplicate_groups or duplicate_of.

Nothing from the plan's LEFT OUT section was built. Verified: the import block is argparse, copy, json, sys, pathlib, typing plus findings, review, verdicts — no subprocess, no HTTP client, no gh; no publish, render or comment module exists in the directory; and grep -rniE "claude|gpt|opus|sonnet|gemini|anthropic|openai|llama|mistral" over the module returns zero hits, so no model is named.

Escalations

  1. This PR introduces no defect of its own. Both findings reported against it were inherited and are homed on earlier branches — the stages shape on feat(launchpad): nonce check and stages manifest (#118 STEP 4) #264, the notes drift on feat(launchpad): run_adjudication.py -- the adjudication CLI (#118 STEP 3) #263 — and both are now fixed there and merged up. This branch is cleared by propagation.
  2. The detection mechanism was a genuine open decision, not specified by the plan. A second injectable callable was chosen over reshaping Judge; if that is the wrong shape, the cost of changing it grows with every step built on top.
  3. review-final has not been run for this branch. The plan's GATES section schedules it at STEP 12, which is where the whole-chain pass belongs — this PR carried an early whole-branch pass over its 11 commits, which is not the same thing.

🤖 Drafted by Claude Code (claude-opus-5) for @serina-mcfall.

Groups findings that describe the same defect in different words, possibly
from different review dimensions (finding_id differs by construction since
dimension is a hash input), into adjudication.duplicate_groups with a
deterministic survivor and a duplicate_of back-reference on every non-survivor
-- discoverable from either the finding or the top-level block.

The Judge protocol (judge(finding, input_document) -> dict) is called once
per finding, independently, so it structurally cannot see across findings to
detect a duplicate. Rather than reshape that existing, already-tested
protocol, dedupe gets its own separate injectable callable, dedupe_judge,
called once after every finding is adjudicated over the full list --
mirroring how the primary judge already defaults to stub_judge to prove the
harness before a real model exists. stub_dedupe_judge finds no duplicates by
default: never merging incorrectly is safer than merging wrongly.

Survivor selection (highest severity, then CONFIRMED > UNPROVEN > REFUTED,
then lowest finding_id) is deterministic code, independent of whichever
mechanism decides who is a duplicate of whom.

verdicts.validate already rejects a duplicate_of naming an absent id or
itself (STEP 2); confirmed here with tests, not reimplemented.

Signed-off-by: Serina Mcfall <serina.mcfall@gmail.com>
@ciaran-slow

Copy link
Copy Markdown

Review pipeline — PR #267 (and the whole-branch pass for the #118 stack)

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 7 at :656.
Diffed against this PR's own base (feat/review-agent-adjudication-escalate, #266). As the tip of the stack, this PR also carries the review-final whole-branch pass over all 11 commits — the section after the findings.

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

Ran the suite: Ran 63 tests, OK.

STEP 7's done-when, driven through adjudicate() with injected judges

two findings, two dimensions, one defect → both present, both carry a verdict,
                                            exactly one duplicate_of, one group naming both,
                                            findings_count unchanged, validates clean
survivor stable across two runs           → byte-identical under json.dumps(sort_keys=True)
a run that dedupes nothing                → duplicate_groups present and [], not omitted
duplicate_of naming an absent id          → rejected by verdicts.validate  (checked on #261)
a finding naming itself                   → rejected by verdicts.validate  (checked on #261)

All five pass. I also threw three cases the done-when does not name:

dedupe judge raises                       → fails closed to [], document still validates clean
dedupe judge groups a finding with itself → group dropped (needs 2 distinct real ids)
dedupe judge returns the same pair twice  → first group wins, one group emitted

All three handled. I found no new defect in STEP 7 itself. _build_duplicate_groups is defensive in the module's established style and its docstring enumerates each guard with its reason; _survivor_sort_key implements the plan's three-level rule (severity, then CONFIRMED→UNPROVEN→REFUTED, then lowest finding_id) as a plain sort key, so "the best one" is a rule rather than a judgement, and min() over it plus sorted() on the duplicates makes two runs agree by construction rather than by luck.


Findings

1. Low — missing by:agent label

No labels; the body carries an Agent provenance block. launchpad/AGENTS.md §5 rule 3. This applies to eight of the nine open PRs — only #262 has it.

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

Carried forward — reported elsewhere, not re-counted here

Defect Severity Reported on
whitespace-only verdict_evidence passes the fail-closed guard and the contract check High #261 (verdicts.py:268), #263 (run_adjudication.py:188)
judge-supplied notes silently dropped; adjudicator.md:66 instructs the judge to use them High #265 (the instruction), #263 (:258, the wire)
non-list stages silently discarded — bypasses the re-run guard, can drop a blocked pre-flight High #264 (:258, :474)
out-of-ladder severity published when the judge agrees with it Medium #266 (:487)

All four are still present in this diff's code, reached through the same lines. Counted once each, at the PR that introduced them.


Whole-branch pass (review-final)

Eleven commits, c4eb8bbe8..b5e934904. Read as a range, not as HEAD~1.

1. Does the branch do what #118 asked?

Partly, and honestly so. STEPs 2, 3, 4, 6 and 7 are built here; STEP 1 merged as #256; STEP 5 is #265. STEPs 8–12 are not built — fixtures, recorded judge outputs, the control suite, the mutation proof, and the PR step. Every PR in the stack says Refs #118 rather than Closes #118, which is the correct signal under launchpad/AGENTS.md §6. Nobody is claiming the issue is done.

What a reader of #118 would find working today: a CLI that takes a #117 merged document on stdin, validates it against #117's own contract, verifies the nonce three ways, adjudicates every finding through an injected judge, re-rates severity under a guard, records downgrades, flags total refutation, groups duplicates, and prints one document to stdout. I drove all of that end-to-end rather than reading it.

2. Did the steps drift from each other?

One real drift, and it is the notes field. Traced across every commit in the range:

  • STEP 1 (ADJUDICATION.md:137, already merged) declares it: "array of free-text notes".
  • STEP 2 (verdicts.py:93) carries it as a dataclass field with default_factory=list, and :208 explicitly excludes it from validation.
  • STEP 5 (adjudicator.md:66, feat(launchpad): adjudicator.md -- what the judge is told (#118 STEP 5) #265) makes it normative: a genuinely new defect "does not become a finding: record it in adjudication.notes".
  • STEPs 3–7 (run_adjudication.py:864) hardcode notes=[], and the judge protocol at :226-234 enumerates the keys it honours — verdict, verdict_evidence, severity, severity_reason — with notes absent.

Four steps agree the field exists and one step tells the judge to use it; no step connects the two. Probed: a judge returning a notes key produces adjudication.notes: []. This is invisible per-diff by construction — each step is correct on its own terms — which is exactly the category this pass exists for.

A second, milder one: the same falsiness idiom stands in for non-emptiness at both verdicts.py:268 and run_adjudication.py:188. The steps are consistent with each other and both wrong against the contract, so it reads as a shared convention rather than a drift. Reported on #261/#263.

3. Did the plan's OPEN items stay open?

Checked all nine. Yes, all of them. Two were worth verifying rather than assuming:

  • Whether verdict_evidence needs a second mechanism. adjudicator.md:83-87 restates the exposure and explicitly declines to solve it, naming why a keyword filter would be the wrong fix. Left open.
  • Whether severity may be lowered at all. The plan chose "yes, with a reason and a recorded downgrade" while flagging the opposite reading as defensible. _apply_severity_rerating implements exactly that, and the downgrades list is the artefact the plan said would let the choice be revisited "without rebuilding the stage". The plan decided; the code did not decide something further.

4. Was anything from LEFT OUT built anyway?

No. Verified each:

  • Finding new defects — the runner never adds to reports[].findings; the id set is asserted equal before printing.
  • Deciding whether the PR merges — no approved, mergeable or merge_recommendation key can exist; _find_forbidden_keys walks the whole document for them.
  • Publishing — no publisher module exists on this branch, no subprocess, no HTTP client, no gh call. The module reads stdin and writes stdout.
  • Adjudicating containment findings — probed: a containment finding emerges with keys exactly ['entry_point','evidence','kind','severity'], no verdict added, and the whole containment block is byte-identical to the input.
  • Choosing the model — grepped the runner for every model vendor and family name: no match. The judge is injected.

5. Are steps reported BLOCKED genuinely still blocked?

No step in this stack was reported blocked.

6. Does the commit history tell the truth?

Yes, and unusually so. Seven feat/docs commits and four fix commits, each naming its step in the subject. The four fix commits are the author's own review findings closed inside the branch rather than papered over — f79aa3d64 (unhashable field values), 4d05bfc83 (valid-but-non-object JSON), 36348cc0d (nonce ordering), 2be90c629 (malformed reports shape), e79da04fb (unhashable judge severity). I read 36348cc0d's message expecting it to have broken STEP 3's validate-first guarantee and it had not: "findings.validate still runs before any finding reaches the judge loop — STEP 3's actual guarantee — just second now instead of first." The message reasons correctly about what the guarantee actually was.

7. Was every step actually gated?

The mechanical check does not apply — no SDD ledger, and the plan's headings are STEP N rather than ### Task N:, so check-ledger.sh exits on its vacuity guard rather than reporting anything. Stating that rather than pretending it passed.

What I can say: before this review, none of the nine open PRs had a single review or review comment on it — I checked the reviews and comments APIs on each. Five of the six #118 PRs now have one, and the author's own pre-PR review-code passes are recorded in the PR bodies with the fixing commit named. That is not the same as an independent gate per step, and the honest statement is that this stack has had one independent pass — this one — over five steps at once.

8. Does every field added to a shared contract have a consumer?

This is the question the skill says produced the largest defect cluster elsewhere, so I ran it properly rather than reasoning about it. No consumer exists for anything this stage emits, because #119 — the publisher — is not built: there is no publish, render or comment module anywhere under launchpad/review-agent/, and the only readers of adjudication.* in the tree are verdicts.py, run_adjudication.py and their own tests.

That would be a large finding if the plan had not already said so, field by field, in its OPEN section:

So the producer-with-no-consumer cluster here is deliberate and documented, not an oversight — with one exception, and it is notes, which has no consumer and no producer. That is finding 2 on #265/#263 and it is the only member of this cluster I am reporting.

Triage of deferred and parked items

Nothing arrived deferred or parked from an earlier round — there were no prior reviews on any of these PRs. Triaging what this pass produced instead:

Item Call Why
whitespace verdict_evidence (#261/#263) must-fix before the stack merges it defeats a fail-closed guarantee two modules state explicitly, and can publish a merge-blocking CONFIRMED with no evidence. Two lines.
notes has no wire (#265/#263) must-fix before #265 merges a normative instruction to use a channel that does not exist pushes text into verdict_evidence, the one field with no guard.
non-list stages (#264) must-fix before the stack merges a blocked pre-flight can be dropped and the document publishes as complete — the issue's own fifth-criterion failure, reached through a shape defect. One line at each of two sites.
out-of-ladder severity when the judge agrees (#266) defer, with a control unreachable through main() today. Defer the fix if you like, but add the missing control now, or the next refactor makes it reachable silently.
the four by:agent labels defer — but do them in one sweep pure metadata; costs one command per PR.
test suites not wired into CI defer to STEP 10, and hold STEP 10 to it stated deliberately at test_verdicts.py:9-13 with STEP 10 named as owner. Nothing currently enforces that STEP 10 does it, so this belongs in STEP 10's review as a blocking item. Note this is not the same situation as #260/#262, where nothing was stated.

A correction to my own review of #261

Reading _build_duplicate_groups here changes one thing I wrote on #261. My finding there — that verdicts.validate accepts a duplicate_groups entry with an empty duplicates list and an unvalidated survivor — is still correct as a validator hole, and I re-probed it. But the failure scenario I gave said the runner might emit such a group. It cannot: :672-674 drops any group left with fewer than two distinct real finding_ids, and I probed that too. So that finding is reachable only from a hand-written or forged document, or from STEP 10's planned malformed-document controls — which makes it materially less urgent than I framed it. I have posted the correction on #261 rather than leaving the overstatement standing.

Merge readiness

Merge order is not optional here. The stack is #261#263#264#266#267, each based on its predecessor's branch. #265 is based on launchpad and can land anywhere, but it cites run_adjudication.py normatively, so landing it after #264 avoids a window where launchpad carries a document citing a function no file on launchpad defines.

What a reader of #118 would find true after this stack merges: five of its twelve steps built, each doing what its step specified, with the hard parts — nonce provenance, pass-through fidelity, escalate-only, deterministic dedupe — done correctly and for stated reasons. What they would find missing: STEPs 8–12, correctly not claimed; and four small defects, three of which are channels that do not carry what their documentation says they carry.

What I could not check: whether any of this behaves correctly under a real model. That is STEP 9's job by the plan's own design and every module says so. I tested the harness, the guards and the contract; I did not test a judge.

Independence and tools

Independent of the code under review: I did not write any of it and had no part in it. Not independent across pipeline stages — one context ran the reviewers, the adjudicator and this final pass, where the skills call for a fresh context per stage and a different model where possible. Concretely: the adjudicator did not refute a single reviewer finding here, and a genuinely independent adjudicator over the same reports would be expected to refute some. Read the severities as one reviewer's proposals, not as adjudicated verdicts. Nine candidate findings across the stack were refuted by probe, and I have recorded each on its PR rather than dropping it silently — that is the closest this arrangement gets to the real thing.

Tools actually held and used: Bash (git, git grep, git show, git log, git worktree, git merge-base, git cat-file, gh, gh api, python3 and subprocess for the probes), Read, Edit, Write. No Grep or Glob tool was available in this session — every search was git grep/grep through Bash, which is why the evidence is quoted as commands throughout.

Nothing found at Blocker. Nothing new to this PR above Low.

CONFIRMED	Low	PR #267 (labels)	missing required by:agent label

Handed 1 finding new to this PR, confirmed 1, refuted 0. Four defects reachable in this diff are carried forward from #261, #263, #264, #265 and #266 and are not re-counted here; eight adversarial candidates against STEP 7 were REFUTED by probe and are recorded above. 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 reports readiness; it does not grant it. The merge decision is @ciaran-slow's.

@ciaran-slow ciaran-slow added the by:agent Filed or authored by an AI agent, not a human label Aug 21, 2026
@ciaran-slow ciaran-slow self-assigned this Aug 21, 2026
@ciaran-slow

Copy link
Copy Markdown

Carried-forward severity updated: the stages finding is now a Blocker

@ciaran-slow has promoted finding 1 on #264 from High to Blocker. My review above lists it in the Carried forward table at High — that row is now Blocker.

This PR inherits it. Both guard sites are unchanged from #264, only shifted:

  • launchpad/review-agent/run_adjudication.py:388if not isinstance(stages, list): return
  • launchpad/review-agent/run_adjudication.py:873… if isinstance(input_stages_raw, list) else []

Re-verified through the CLI on the chain tip: an adjudication entry inside an object container adjudicates with exit 0 instead of being refused, and a preflight entry with status: "blocked" is silently discarded so the document publishes as complete.

So this PR is blocking until #264's fix lands and propagates. Do not patch it here — the fix belongs on #264's branch, or the stack ends up with two divergent versions of the same guard.

Nothing else in my review of this PR changes. The reasoning and the re-verification are on #264: #264 (comment)

The verdict block in my review above stands as written — it covers only findings new to this PR, and this defect is counted once, on #264.

@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, and this PR also carried the review-final whole-branch pass over all 11 commits — detail in my two comments. Ran 63 tests ... OK.

No new defect in STEP 7 itself. All five done-when clauses pass, plus three adversarial cases the plan does not name: a dedupe judge that raises fails closed to []; one that groups a finding with itself has the group dropped; one that returns the same pair twice yields a single group. Two runs of the same input are byte-identical under json.dumps(sort_keys=True). _survivor_sort_key implements the plan's three-level rule as a plain sort key, so "the best one" is a rule rather than a judgement.

The whole-branch pass came back clean on most of what only it can see: the commit history tells the truth (four fix commits are the author's own review findings closed inside the branch, and 36348cc0d's message reasons correctly about what STEP 3's guarantee actually was); all nine of the plan's OPEN items stayed open; nothing from LEFT OUT was built — no publisher, no model named, containment findings passed through untouched.

One inherited Blocker, and nothing else.

Non-list stages silently discarded:388 and :873 here, unchanged from #264, and I reproduced the bypass through the CLI on this exact branch: an adjudication entry inside an object container adjudicates at exit 0 instead of being refused, and a preflight entry with status: "blocked" is dropped so the document publishes as complete. Fix on #264's branch and let it propagate.

Two things from the branch pass worth carrying forward rather than acting on here:

  • One real cross-step drift: notes. Four steps agree the field exists and STEP 5 tells the judge to use it; no step connects them. ADJUDICATION.md:137 declares it, verdicts.py:93 carries it, adjudicator.md:66 mandates it, run_adjudication.py:864 hardcodes it empty. Invisible per-diff by construction. Resolution belongs on #263.
  • Producer-with-no-consumer is deliberate here, not an oversight. #119 is not built, so nothing reads anything this stage emits — but the plan's OPEN section names each field and says so, field by field. The only exception is notes, which has no consumer and no producer, and that is the drift above.

STEPs 8–12 are correctly not claimed; every PR in the stack says Refs #118, not Closes.

@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, and this PR also carried the review-final whole-branch pass over all 11 commits — detail in my two comments. Ran 63 tests ... OK.

No new defect in STEP 7 itself. All five done-when clauses pass, plus three adversarial cases the plan does not name: a dedupe judge that raises fails closed to []; one that groups a finding with itself has the group dropped; one that returns the same pair twice yields a single group. Two runs of the same input are byte-identical under json.dumps(sort_keys=True). _survivor_sort_key implements the plan's three-level rule as a plain sort key, so "the best one" is a rule rather than a judgement.

The whole-branch pass came back clean on most of what only it can see: the commit history tells the truth (four fix commits are the author's own review findings closed inside the branch, and 36348cc0d's message reasons correctly about what STEP 3's guarantee actually was); all nine of the plan's OPEN items stayed open; nothing from LEFT OUT was built — no publisher, no model named, containment findings passed through untouched.

One inherited Blocker, and nothing else.

Non-list stages silently discarded:388 and :873 here, unchanged from #264, and I reproduced the bypass through the CLI on this exact branch: an adjudication entry inside an object container adjudicates at exit 0 instead of being refused, and a preflight entry with status: "blocked" is dropped so the document publishes as complete. Fix on #264's branch and let it propagate.

Two things from the branch pass worth carrying forward rather than acting on here:

  • One real cross-step drift: notes. Four steps agree the field exists and STEP 5 tells the judge to use it; no step connects them. ADJUDICATION.md:137 declares it, verdicts.py:93 carries it, adjudicator.md:66 mandates it, run_adjudication.py:864 hardcodes it empty. Invisible per-diff by construction. Resolution belongs on #263.
  • Producer-with-no-consumer is deliberate here, not an oversight. #119 is not built, so nothing reads anything this stage emits — but the plan's OPEN section names each field and says so, field by field. The only exception is notes, which has no consumer and no producer, and that is the drift above.

STEPs 8–12 are correctly not claimed; every PR in the stack says Refs #118, not Closes.

@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: b5e934904, the same commit named as the end of the reviewed range. Nothing fixed since. Suite verified exactly:

$ python3 -m unittest test_run_adjudication      -> Ran 63 tests in 0.247s  OK
$ python3 -m unittest test_verdicts              -> Ran 17 tests in 0.002s  OK
$ python3 -m unittest test_run_adjudication test_verdicts  -> Ran 80 tests  OK

The reported 63 is test_run_adjudication alone — real, green, and honest, but worth knowing which 63; it is not the whole review-agent suite.

Bottom line: nothing this PR introduces blocks it. Both findings are inherited and homed on earlier branches. Because this PR was declared clean, the highest-value work here was attacking the positive claims — five reached, all held, two phrasings corrected, and six left unverified and named below.

Findings

1. run_adjudication.py:388 and :873 — inherited non-list stages — CONFIRMED, reproduced independently rather than deferred.

No upstream guard: grep -n stages findings.py verdicts.py returns zero hits, so a non-list value passes findings.validate cleanly and reaches _check_not_already_adjudicated, which returns early on it.

Probed through the real CLI on this branch:

A CONTROL: stages LIST with an adjudication entry  -> exit 1, correctly refused
B PROBE:   stages as an OBJECT wrapping one        -> exit 0                    <-- guard bypassed
C PROBE:   stages as an OBJECT, blocked preflight  -> exit 0, entry GONE, publishes "complete"

The control proves the guard works on the shape it expects. C is the worse half: {"name":"preflight","status":"blocked","reason":"containment blocked"} is dropped and the document publishes with a lone adjudication entry at status: "complete".

Same root cause as #264, line-shifted only — verified here rather than taken on trust. At #264's head the identical guards sit at :258/:474, and _check_not_already_adjudicated is byte-for-byte the same function including its docstring. Nothing drifted.

Severity: High, adjudicated at the root cause on #264 (where the promoted Blocker was moved down — the two "breaks a stated rule" arguments did not survive checking; one of them, the "Every container is type-checked" docstring, does not even exist on #264's branch). High blocks the merge, so the fix must still land before this chain does.

This PR should not be blocked in its own right. The severity attaches to the root cause; #267 clears automatically when #264's fix propagates. Do not patch it here — two divergent copies of one guard is worse than the guard.

2. run_adjudication.py:864notes cross-step drift — CONFIRMED, Medium (moved down from High). Home: #263.

Verified at all four sites, including the one on another branch:

Site What it says
ADJUDICATION.md:137 notes — array of free-text notes
verdicts.py:93 notes: list = field(default_factory=list)
verdicts.py:203-208 notes is "unchecked here on purpose"
run_adjudication.py:864 notes=[], — hardcoded
run_adjudication.py:226-234 judge protocol enumerates verdict, verdict_evidence, severity, severity_reason. notes absent
adjudicator.md:66 (#265) "it does not become a finding: record it in adjudication.notes" — normative

Probed: a judge returning {"verdict":"CONFIRMED","verdict_evidence":"...","notes":["NEW DEFECT: ..."]} yields adjudication.notes: [], and the document validates clean against both contracts. The channel the prompt mandates is silently inert and nothing catches it.

The decisive test — is it a documented deferral? No. The plan's OPEN section was read in full: it names verdict, verdict_evidence, verdict_counts, downgrades, duplicate_groups and total_refutation as producer-with-no-consumer, each with its reason. notes is not in OPEN at all. It appears only at the schema line and twice in LEFT OUT, both saying a new defect "goes to adjudication.notes" — a positive requirement, the opposite of a deferral. So the finding does not weaken.

Medium rather than High: on this branch notes has neither producer nor consumer, so nothing is lost because nothing writes it, and the judge protocol docstring does enumerate the honoured keys, giving a maintainer a fair chance of noticing. It becomes High the moment #265 lands — 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.

Home is #263, which introduced both the judge protocol and the notes=[] literal. The declaration is already merged (ADJUDICATION.md via #256, verdicts.py via #261) and the instruction is #265. Fixing at #263 reaches #264, #266 and #267 in one move. #265 is off the chain and must not merge ahead of #263's fix without a stated deferral.

Adversarial spot-checks of the "no new defect" claims

(a) Is _survivor_sort_key total? — YES. Verified. No non-determinism.

:613-630 returns (severity_rank, verdict_rank, finding_id). A tie at all three levels would need two group members sharing a finding_id, and they cannot: _build_duplicate_groups:679-687 builds candidate_ids with fid not in candidate_ids, and every id must be in findings_by_id — a dict keyed by finding_id, populated only if isinstance(fid, str). Every candidate is a distinct string id, so the third level is a strict tiebreaker and min() at :690 cannot depend on iteration order. The "" fallback at :629 is unreachable from adjudicate().

Also checked, and not stated in the review: review.py:32 is SEVERITY_ORDER = {"Blocker": 0, "High": 1, "Medium": 2, "Low": 3}, so min() over the key really does pick the highest severity, as the docstring claims. Had that map been the other way round the survivor rule would have silently inverted on every group. It is right.

(b) Does a raising dedupe judge fail closed to []? — YES, with one caveat.

_run_dedupe_safely:643-649 is try / except Exception: return [], then return raw_groups if isinstance(raw_groups, list) else []. Both the raise and the non-list return fail closed.

Caveat, recorded so it is not mistaken for total coverage: except Exception does not catch BaseException, so a dedupe judge raising SystemExit or KeyboardInterrupt propagates and aborts the run. Not a finding — the same convention _run_judge_safely already uses, and aborting on SystemExit is arguably correct.

(c) Was anything from LEFT OUT built? — NO. Verified. One phrasing corrected.

  • Publishing: the import block is argparse, copy, json, sys, pathlib.Path, typing.Callable plus findings, review, verdicts. No subprocess, no HTTP client, no gh. No publish, render or comment module in the directory. Holds.
  • Choosing the model: grep -rniE "claude|gpt|opus|sonnet|gemini|anthropic|openai|llama|mistral" over the module — zero hits. Holds.
  • Deciding whether the PR merges: the review states "no approved, mergeable or merge_recommendation key can exist; _find_forbidden_keys walks the whole document for them." The guard exists and is genuinely recursive over dicts and lists (verdicts.py:34, :184-199, called at :495) — but it lives in verdicts.validate, and neither adjudicate() nor main() calls verdicts.validate on its own output before printing. So it is a validator a downstream caller may run, not an enforcement in the producer. The conclusion still holds — the runner writes only six known keys per finding plus the nine-key adjudication block, so it never produces a forbidden key — but "no key can exist" is stronger than the code supports. Not a defect; recorded because the phrasing would mislead anyone relying on it.

What was NOT reached — silence here is not confirmation

  • Two runs byte-identical under json.dumps(sort_keys=True) — not executed. The load-bearing premise is now verified (the sort key is total, so min() is order-independent; duplicates is sorted(); groups appends in the judge's order), so the claim is well-founded — but by reasoning, not measurement.
  • "Groups a finding with itself -> dropped" and "same pair twice -> one group" — not executed. Both follow by inspection from the len(candidate_ids) < 2: continue gate and the claimed set, but neither was driven.
  • "All nine OPEN items stayed open" — the OPEN section was read in full for finding 2, but not audited item-by-item against the code. Unverified.
  • "The commit history tells the truth", including 36348cc0d's reasoning about STEP 3's guarantee — the four fix commit subjects were confirmed to exist, nothing more. Unverified.
  • Containment pass-through byte-identity — not executed here. A test asserting it is inside the green 63. (It was independently probed on feat(launchpad): escalate-only enforcement and total-refutation status (#118 STEP 6) #266 and held.)
  • The reviewer's own Low new to this PR — the missing by:agent label — was not in the handed set and is not adjudicated in the table below.

Verdict

state severity file:line summary
CONFIRMED High launchpad/review-agent/run_adjudication.py:388 Non-list stages bypasses the re-run guard (exit 0) and drops a blocked pre-flight so the document publishes complete (also :873); byte-identical to #264 :258/:474fix on #264
CONFIRMED Medium launchpad/review-agent/run_adjudication.py:864 adjudication.notes hardcoded [] and the judge protocol ignores a notes key while adjudicator.md:66 mandates it; absent from the plan's OPEN list, so undocumented rather than deferred — fix on #263, and High once #265 lands

Handed 2. 2 confirmed, 0 refuted, 0 merged. One severity moved down. Both findings homed on earlier branches; neither is this PR's to fix.

No total refutation here — both findings were confirmed, and every positive claim reached held up, with two phrasings corrected and six claims left unverified and named above.

🤖 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
…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>
…scalate' into feat/review-agent-adjudication-dedupe

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>
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, including its review-final whole-branch pass).

Checked out feat/review-agent-adjudication-dedupe and ran the suite: Ran 230 tests ... OK in a clean worktree.

The inherited stages-shape Blocker is fixed here too, via 965c2325b ("Merge STEP 6 into STEP 7, carrying the stages-shape fix"). Re-ran the CLI probe myself on this exact branch (object-shaped stages with a blocked pre-flight entry) rather than assuming the merge carried it cleanly:

stages = {"p": {"name":"preflight","status":"blocked",...}} -> exit 1, refused

Confirmed fixed on the chain tip.

The out-of-ladder-agreement Medium from #266 is also still present here (same code, unchanged): _apply_severity_rerating("x", "Info", "CONFIRMED", "Info", None, []) still returns ('CONFIRMED', 'Info', None) on this branch. Same assessment as on #266 — not reachable via main() today, not requesting changes on this branch since it isn't this PR's own defect, but noting it doesn't disappear just because it's inherited.

Dedupe logic itself (_build_duplicate_groups/_survivor_sort_key/_run_dedupe_safely, :700-783) — read it directly rather than trusting the standing review's account:

  • _run_dedupe_safely fails closed to [] on either a raised exception or a non-list return — confirmed by reading the try/except+type-check, matches the claim.
  • _build_duplicate_groups's claimed set correctly prevents one finding from landing in two groups (first group wins, in the order the dedupe judge returned them) and drops any group left with fewer than two real, distinct finding_ids after filtering — covers self-duplication and the "same pair returned twice" case structurally, not just by the specific tests that exercise them.
  • _survivor_sort_key's tiebreak chain (severity rank, then verdict rank, then finding_id) means min() never hits a true tie, since finding_id is already deduplicated into the candidate list before min() runs — so the "byte-identical across repeated runs" claim (json.dumps(sort_keys=True)) is structurally justified, not just empirically observed to hold in the tests that check it.
  • Ordering in adjudicate() (:895-945) is dedupe-after-verdict-and-severity-are-final, called exactly once with the whole finding list, never per-finding — matches the docstring's claim, and a duplicate is never removed from findings_out's count, confirmed by reading where findings_out is set (before the dedupe block runs at all).

Agree with the standing review's finding-count for STEP 7 itself: no new defect in the dedupe logic beyond what's inherited from earlier steps.

Not verified: no live-model dependency in this step; did not independently re-run the full review-final whole-branch pass over all 11 commits that the standing review describes (commit-history audit, OPEN-items-stayed-open check) — took that account at face value rather than re-deriving it, since it's assertions about history and scope rather than about code behavior.

serina-mcfall added a commit that referenced this pull request Aug 21, 2026
 STEP 6)

benmitchell11's independent pass on #266/#267 found that
_apply_severity_rerating's no-re-rating branch returned
reported_severity without ever checking it, so an out-of-ladder value
the judge agreed with (or said nothing about) was published untouched:

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

Reproduced before fixing. This is a plan-conformance gap, not merely
defence in depth: STEP 6's own done-when names this exact case -- "a
guard watching only re-ratings never sees a finding that ARRIVED at
'Info' and was agreed with, and copies it into `severity` untouched" --
and ADJUDICATION.md promises the guarantee holds on the EFFECTIVE
severity, the re-rating where there is one and reported_severity where
there is not.

Fixed by checking reported_severity inside that branch, ahead of the
return that used to copy it: verdict becomes UNPROVEN, severity falls
back to Blocker (not something smaller -- this stage may not decide an
unrateable finding is minor), severity_reason names the refusal, and
nothing is added to downgrades since no legal value fell.

Unreachable through main() today (STEP 3's findings.validate refuses an
out-of-ladder input severity before any judge runs) and kept as a real
branch regardless: adjudicate() is importable, and STEP 10's control
suite is planned to feed this function malformed values directly.

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. 71 tests OK, run_controls 13/13, all seven suites OK.

Signed-off-by: Serina Mcfall <serina.mcfall@gmail.com>
Signed-off-by: Serina Mcfall <serina.mcfall@gmail.com>
@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.

@serina-mcfall

Copy link
Copy Markdown
Author

Requested changes are done — but I am not the right party to certify it

Disclosure first: I wrote one of the commits on this branch (965c2325b, the propagation merge). Status report, not an independent review.

@ciaran-slow's change-request (2026-08-21T03:57Z) is stale — the head has moved to c60f9da5e. Both findings were homed on earlier branches, exactly as the review asked, and both arrive here by propagation:

So this PR is cleared by propagation rather than by any change of its own — which is what the review asked for, and worth recording as the correct outcome rather than an absence of work.

Still open and not blocking this PR: #265 holds the adjudicator.md amendment and is off this chain. It should not merge ahead of #263's deferral note, or the repo briefly holds a normative instruction the code cannot honour. Ordering, not a defect.

One thing an independent reviewer should press on that I cannot: the adversarial pass on this PR reported no new defect in STEP 7 itself, and six of its positive claims were confirmed while six were explicitly left unverified — including whether two runs are byte-identical, and whether the plan's nine OPEN items all stayed open. Those gaps are named rather than papered over, but they are gaps.

What this needs: a re-review from @ciaran-slow rather than a clearance from me. Re-requested. CI is green.

@tucktuck101
tucktuck101 merged commit 39c07cb into feat/review-agent-adjudication-escalate Aug 23, 2026
23 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.

4 participants