feat: adjudication succession — judge/synthesizer failover on infrastructure errors (DSE-1512) - #63
Merged
Merged
Conversation
…wer (DSE-1512) Claude-Session: https://claude.ai/code/session_01K1dHPjZ1bZcE2GnX3KMMSH
…DSE-1512 review) - Council.adjudicate: replace the four near-identical AdjudicationAttempt constructors with one local _attempt() closure, binding the per-iteration candidate/model_id/index as default-argument values so the closure can't drift onto a later loop iteration's values (flake8-bugbear B023). Behavior unchanged. - synthesize_blocks: the synthetic ModelAnswer returned when every chain candidate is unkeyed now carries failure_category="unkeyed" like every other unkeyed outcome, instead of leaving the field untyped. - tests/test_adjudication.py: new test_synthesize_blocks_all_unkeyed_synthetic_answer_is_typed pins the new failure_category. Claude-Session: https://claude.ai/code/session_01K1dHPjZ1bZcE2GnX3KMMSH
…ion seam (DSE-1512) Prose synthesis (Council._synthesize), debate's final consolidation (modes._debate_synthesize), the adversarial judge (modes._adversarial_judge), and Elite's synthesis (Council.elite) now all call Council.adjudicate() directly and record the outcome via Council._record_adjudication(), instead of going through the bare Council.synthesize_blocks() wrapper. Each site now: - consults the FULL synthesizer_chain (not just the primary) when deciding whether anything is keyed, with a new Council._skipped_attempts() helper recording one skipped_unkeyed ledger entry per candidate when none is; - records the complete succession ledger (ModelHarnessManifest. adjudication_succession) for its role (synthesis/debate_final/judge), plus one execution receipt per REAL call (not just the final winner); - sets synthesizer/judge identity fields from whichever candidate actually answered, falling back to the configured primary when nothing could be called. Chain-of-one behavior (the v1.3.0 default) is unchanged byte-for-byte: same messages, same synthesis/synthesis_error/verdict_error text, same receipt counts. Pinned by four *_no_key_message_unchanged tests. Deliberate, documented behavior change: debate and adversarial manifests now carry a receipt for the debate_final/judge call, which they previously omitted entirely (only member/proposal/critique receipts were recorded). This closes the same completeness gap Elite's synthesis receipt already closed. No pre-existing assertion needed adjustment -- the full suite passed unchanged before any new tests were added, confirming no test currently counts receipts/usage/latency precisely for debate or adversarial mode. Plumbing: - Council._ensure_manifest is now called BEFORE synthesis/judging in elite()/run_debate()/run_adversarial() (previously after), so _record_adjudication always has a manifest to append to. - _ask_uncached and elite() no longer manually append a synthesis receipt; _record_adjudication (inside _synthesize) owns it now, preventing doubled receipts. - tests/conftest.py: new shared helpers make_failed_answer/make_ok_answer/ install_council_script and a `keys` fixture (5 provider env vars), used by tests/test_adjudication.py (refactored to import them) and the new tests in tests/test_council.py, tests/test_modes.py, tests/test_elite_mode.py. Claude-Session: https://claude.ai/code/session_01K1dHPjZ1bZcE2GnX3KMMSH
…nd-record tail (DSE-1512 review) Extract two Council helpers from the ~15-line block repeated at three call sites (_synthesize, modes._debate_synthesize, modes._adversarial_judge): - _chain_unkeyed_error(actor, suffix): the shared 'is the whole chain unkeyed' check + message formatting (chain-of-one keeps the historic single-candidate wording verbatim). - _adjudicate_and_record(...): adjudicate() then _record_adjudication(), the common tail of every adjudication role. Every call site now reads the same shape: set the primary on the result/adv, check _chain_unkeyed_error, adjudicate_and_record, then assign the successor's name/model_id only when outcome.answer is not None (the guarded form _adversarial_judge already used) -- made consistent across all three sites. Message strings are byte-identical; the four *_no_key_message_unchanged tests plus the full suite (791 passed) confirm it. modes.py no longer needs the now-unused key_present import. Claude-Session: https://claude.ai/code/session_01K1dHPjZ1bZcE2GnX3KMMSH
Council._apply_verdict now walks self.synthesizer_chain instead of calling extract_verdict against the primary synthesizer only. Verdict extraction is not one call (extract_verdict makes an initial structured call plus one same-model repair retry, validates JSON, and computes consensus), so the chain walk lives in _apply_verdict, calling extract_verdict once per candidate and classifying from what it reports (verdict_absent_reason / failure_category / http_status) rather than from a raw ModelAnswer. - verdict_synthesis.py: VerdictSynthesisResult gains failure_category / http_status, populated only on the REASON_EXTRACTION_FAILED path from the LAST attempt made (the repair retry when one ran, else the initial call). A model that answered unusably gets the fixed 'malformed_response' category (never in FAILOVER_CATEGORIES, so always terminal for the role); an infra failure copies the ModelAnswer's own category verbatim. Public REASON_TOO_FEW / REASON_OPEN_ENDED / REASON_EXTRACTION_FAILED aliases sit alongside the existing private constants (unchanged, unrenamed) so council.py has a non-underscored import. - council.py: _apply_verdict's chain walk calls extract_verdict once per candidate, appends one AdjudicationAttempt per candidate to the manifest's adjudication_succession ledger, and provenance/hoisting use the LAST candidate consulted. Unkeyed candidates are deliberately NOT pre-skipped for this role (unlike adjudicate()): extract_verdict already runs to completion for an unkeyed candidate (two failed no-network calls), and its 'unkeyed' failure_category is itself in FAILOVER_CATEGORIES, so the chain-of-one byte-for-byte receipt shape is preserved while still producing a 'failed_over'/'exhausted' ledger entry instead of the skipped_unkeyed outcome every other role uses. Five new tests in test_council_verdict.py cover fail-over to a successor, a terminal schema failure that never consults the next candidate, the chain-of-one unkeyed path (asserting today's receipt shape is unchanged), full chain exhaustion, and the N<2 gate recording no ledger entries. Full suite: 796 passed (791 baseline + 5 new). extract_verdict's repair-retry behavior (always same-model) is unchanged. Claude-Session: https://claude.ai/code/session_01K1dHPjZ1bZcE2GnX3KMMSH
…le (DSE-1512 review) Claude-Session: https://claude.ai/code/session_01K1dHPjZ1bZcE2GnX3KMMSH
…d over (DSE-1512) Claude-Session: https://claude.ai/code/session_01K1dHPjZ1bZcE2GnX3KMMSH
…SE-1512 review) Claude-Session: https://claude.ai/code/session_01K1dHPjZ1bZcE2GnX3KMMSH
…E, PDD §4a (DSE-1512) Claude-Session: https://claude.ai/code/session_01K1dHPjZ1bZcE2GnX3KMMSH
…n stream; primary_failed_over counts a successor (DSE-1512 review) Three blocking defects from the QA adversarial review, its minor items, and one CSO typing finding, landed as one unit. - verdict extraction's failure category is now decided by whether the candidate EVER answered across its initial/repair attempts, not by whichever attempt happened to run last. Previously an initial call that answered (200 + unparsable prose) followed by a repair retry that hit an unrelated infra error would classify as "quota"/"auth"/etc and wrongly fail the role over to the next chain candidate after the primary had already responded. `_extraction_failure_category` now takes both attempts and returns the fixed terminal `"malformed_response"` when either answered, else the last errored attempt's real category. - `Council.ask_stream`'s cache store now honors the same no-store-on- primary-failover rule as the buffered path (`_cached_run`): a run whose primary adjudicator failed over or exhausted for an infrastructure reason is never persisted, matching the existing buffered-path log wording. - `CouncilResult.primary_failed_over` now also reports `True` when a successor adjudicated after the primary was merely SKIPPED for a missing key (no `failed_over`/`exhausted` ledger entry exists for that shape, so it previously read `False` even though the primary never ran). A chain-of-one unkeyed primary (nothing to succeed to) is unaffected. - CSO finding: `AdjudicationAttempt.failure_category` and `VerdictSynthesisResult.failure_category` are now the bounded `FailureCategory` literal instead of `str | None`, so an arbitrary error string can never reach the ledger. - QA minors: verdict-extraction receipts now renumber `attempt` to stay monotonic across chain candidates instead of colliding at 1 per candidate (M2); `categorize_http_status` maps out-of-range statuses (1xx/3xx/etc) to `"unexpected"` instead of the semantically wrong `"malformed_response"` (M3); the `failed_over` ledger-outcome comment and `synthesize_blocks`'s stale docstring are corrected (M1, M4). 830 tests passing (817 + 13 new), ruff clean. Claude-Session: https://claude.ai/code/session_01K1dHPjZ1bZcE2GnX3KMMSH
…ity note (DSE-1512 review) - README.md: reworded the "Synthesizer failover" cache sentence to match primary_failed_over's exact rule (adjudicated by a successor OR ladder exhausted); corrected the unkeyed/unresolved table row to "no network request is made"; documented that verdict extraction attempts every candidate (including unkeyed ones, which fail before any network request) so its receipts stay complete; added a Confidentiality note that a declared chain widens which vendors receive the prompt. - CHANGELOG.md [Unreleased]: aligned the cache bullet's wording with primary_failed_over and noted it covers --stream; added redacted_errors to the debate/adversarial receipt-aggregate bullet; rewrote the repair- retry "Not changed" bullet to state the ever-answered rule; added a one-line Added bullet on chain confidentiality. - docs/PRODUCT_DESIGN_DOCUMENT.md: aligned §4a's cache sentence with the README wording, added the verdict-extraction "ever answered" rule, and qualified the §2 persona table's "no third party seeing the prompt" cell with a pointer to §4a. - SECURITY.md: added a "Scope notes" bullet on synthesizer failover confidentiality (security-specific change; this PR is already classified as such). - src/conclave/cli.py: --synthesizer help now names missing-key/unknown- provider in the failover trigger list, not just auth/quota/5xx/timeout/ network. 830 tests passing, ruff clean. Claude-Session: https://claude.ai/code/session_01K1dHPjZ1bZcE2GnX3KMMSH
…size_blocks (DSE-1512 review) Claude-Session: https://claude.ai/code/session_01K1dHPjZ1bZcE2GnX3KMMSH
…nfrastructure failure for the cache rule (DSE-1512 review) Claude-Session: https://claude.ai/code/session_01K1dHPjZ1bZcE2GnX3KMMSH
Member
Author
Round 3 reviews (read-only, on the builder) — summaryChief Security Officer review —
|
…n --json docstring (DSE-1512 review nits) Claude-Session: https://claude.ai/code/session_01K1dHPjZ1bZcE2GnX3KMMSH
ernestprovo23
marked this pull request as ready for review
September 3, 2026 12:42
Member
Author
|
Merge receipt (release-control, security-specific). Approval received in the authenticated Claude Code chat from the repository owner on 2026-09-04: |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Closes DSE-1512 — follow-up to DSE-901 (v1.3.0), which made a judge/synthesizer failure visible (exit
3) but deferred failover.One synthesizer identity served three roles (prose synthesis, adversarial judge, verdict extraction); a single vendor outage zeroed all three. This PR adds an operator-declared succession ladder with one strict rule, and records every attempt on the manifest.
--synthesizer claude>grok>gemini· configsynthesizer_chain: [claude, grok, gemini]·Council(synthesizer="claude>grok")/ list. Chain of one == v1.3.0 behaviour.unkeyed/unresolvedauthquotaunavailabletimeout/transportbad_requestmalformed_responsesynthesis,debate_final,judge,verdict_extraction, and streaming synthesis (fails over only before the first token).ModelHarnessManifest.adjudication_succession— per attempt:role, candidate, model_id, attempt_index, outcome, failure_category, http_status. No free text (a raw error body could contain "authorization" and un-verify thesecret_safetystamp).result.synthesizer/adversarial.judgename the candidate that actually adjudicated. A successor run exits0;degraded/ exit3now means the ladder was exhausted. NewCouncilResult.primary_failed_overcomputed field.conclave providersshows the configured chain.Changed (deliberate)
debate/adversarialmanifests now carry the judge / final-consolidation receipt they previously omitted (every real call gets a receipt).CACHE_FORMAT_VERSION3 → 4 (old entries miss safely). A run adjudicated by a successor, or whose ladder was exhausted, is never stored (buffered and--stream) — a hit must never pin a non-primary result or replay an outage. Chain-of-one consequence: a degraded run whose sole synthesizer errored for an infra reason is no longer cached.terminal_failureruns stay cacheable.ProviderError/TransportErrorgain keyword-onlycategory(+http_status); positional construction unchanged.Not changed (deliberate)
ReceiptErrorCategoryon receipts.redact(),scan_for_secret_material(),verified_secret_safety(),_receipt_error_category()— untouched (verified per unit).Release classification
release_control.py classify→ security-specific (security-control behavior changed). This is the classifier's vocabulary rule — the changed lines necessarily containrole,token,secret,redact,provenance,validation— not a change to any security control. Perrelease-control.mdthe classifier is the authority: merge requires one human approval receipt at the exact head SHA. CSO + QA read-only reviews attached in the PR comments.Verification
docs/plans/2026-09-03-adjudication-succession.md(10 tasks, TDD, two-stage review per unit).0e18a18/618c26a) and QA adversarial review (FIX FIRST → the verdict-extraction "ever answered" rule, the--streamcache guard, andprimary_failed_overcounting a successor after a skipped primary, all fixed in0e18a18; re-verified on the final head). Summaries in the PR comments.ruff check+ruff format --checkclean (incl. Markdown Python blocks).pytest3.11 / 3.12 / 3.13,ruff,pip-audit,Gitleaks.🤖 Generated with Claude Code
https://claude.ai/code/session_01K1dHPjZ1bZcE2GnX3KMMSH