Skip to content

feat: adjudication succession — judge/synthesizer failover on infrastructure errors (DSE-1512) - #63

Merged
ernestprovo23 merged 21 commits into
mainfrom
feat/dse-1512-adjudication-succession
Sep 4, 2026
Merged

feat: adjudication succession — judge/synthesizer failover on infrastructure errors (DSE-1512)#63
ernestprovo23 merged 21 commits into
mainfrom
feat/dse-1512-adjudication-succession

Conversation

@ernestprovo23

@ernestprovo23 ernestprovo23 commented Sep 3, 2026

Copy link
Copy Markdown
Member

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 · config synthesizer_chain: [claude, grok, gemini] · Council(synthesizer="claude>grok") / list. Chain of one == v1.3.0 behaviour.
  • Failover fires only on infrastructure failures. Typed at the raise site — never from error text:
Category Trigger Next candidate?
unkeyed / unresolved no key / unknown provider yes (no call)
auth 401 / 403 yes
quota 402 / 429 yes
unavailable 5xx yes
timeout / transport deadline, DNS, connection yes
bad_request other 4xx no
malformed_response 2xx, unusable content no — the model answered
  • Roles covered: synthesis, 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 the secret_safety stamp).
  • result.synthesizer / adversarial.judge name the candidate that actually adjudicated. A successor run exits 0; degraded / exit 3 now means the ladder was exhausted. New CouncilResult.primary_failed_over computed field.
  • CLI prints one dim stderr line on failover; conclave providers shows the configured chain.

Changed (deliberate)

  • debate / adversarial manifests now carry the judge / final-consolidation receipt they previously omitted (every real call gets a receipt).
  • Cache: full chain in identity; CACHE_FORMAT_VERSION 3 → 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_failure runs stay cacheable.
  • ProviderError / TransportError gain keyword-only category (+ http_status); positional construction unchanged.

Not changed (deliberate)

  • Verdict extraction's same-model repair retry still runs after an infra error — follow-up. Its outcome can never turn a content failure into a failover: a candidate's fate is decided by whether it ever answered.
  • Member-level failover, transport retries, the substring-derived ReceiptErrorCategory on receipts.
  • redact(), scan_for_secret_material(), verified_secret_safety(), _receipt_error_category() — untouched (verified per unit).

Release classification

release_control.py classifysecurity-specific (security-control behavior changed). This is the classifier's vocabulary rule — the changed lines necessarily contain role, token, secret, redact, provenance, validation — not a change to any security control. Per release-control.md the 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

  • Plan: docs/plans/2026-09-03-adjudication-succession.md (10 tasks, TDD, two-stage review per unit).
  • Round 3: CSO read-only review (YELLOW → all three findings fixed in 0e18a18 / 618c26a) and QA adversarial review (FIX FIRST → the verdict-extraction "ever answered" rule, the --stream cache guard, and primary_failed_over counting a successor after a skipped primary, all fixed in 0e18a18; re-verified on the final head). Summaries in the PR comments.
  • Suite on the builder: 733 → 830 passed, 0 failures; ruff check + ruff format --check clean (incl. Markdown Python blocks).
  • CI: pytest 3.11 / 3.12 / 3.13, ruff, pip-audit, Gitleaks.

🤖 Generated with Claude Code

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
…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
@ernestprovo23

Copy link
Copy Markdown
Member Author

Round 3 reviews (read-only, on the builder) — summary

Chief Security Officer review — ab47f60YELLOW, all findings resolved

  • Control-change determination: no authentication / credential / redaction / secret-scan / transport-hardening control changed behaviour, scope, enforcement, bypass, or configuration. redact, scan_for_secret_material, verified_secret_safety, _receipt_error_category, _resolve_key, key_present, key_source byte-identical to main (AST hash); registry.py untouched; transport from None + __context__ = None discipline intact at all raise sites; ProviderError still redacts on construction; the new category / http_status attributes cannot carry a request reference.
  • The security-specific classification is the classifier's vocabulary rule (role, token, secret, redact, provenance, validation in the changed lines) plus the SECURITY.md control-plane path once the scope note was added.
  • Noted improvement: failover decisions consume raise-site-typed categories, never _receipt_error_category()'s substring matching — a provider cannot steer a vendor swap with crafted error text, only with its HTTP status.
  • Findings → fixes: (1) ask_stream cached without the no-store guard → guarded (0e18a18); (2) chain confidentiality (a chain widens which declared vendors receive the prompt) undocumented → README › Confidentiality, SECURITY.md scope note, PDD §2 qualifier (618c26a); (3) failure_category typed strFailureCategory literal on the ledger and VerdictSynthesisResult (0e18a18).
  • Security suites on the final head: test_keyleak_audit 23, test_secret_safety_matrix 7, test_transport 8, test_failure_category 24+ — all pass.

QA adversarial review — ab47f60FIX FIRST, re-verified on the final head → SHIP

  • Critical (fixed 0e18a18): verdict extraction read the failure category from the last attempt, so "initial 200-prose → repair 429" failed over after the model had answered. Rule now: a candidate that ever answered is terminal; only two errored attempts can fail over. Probed all three directions.
  • Important (fixed 0e18a18): ask_stream store site lacked the primary_failed_over guard.
  • Important (fixed 0e18a18, then made uniform in the final commit): an unkeyed primary was skipped_unkeyed, not counted as a primary failure, so a successor-adjudicated run was cached under a key that matches once the key is restored. Final rule: the primary adjudicator (attempt 1 of any role) did not adjudicate for an infrastructure reason — missing key included — or the ladder was exhausted ⇒ never stored; terminal_failure stays cacheable.
  • Minors fixed: failed_over comment wording; verdict receipts renumbered across candidates; categorize_http_status 1xx/3xx → unexpected; stale synthesize_blocks / modes.py docstrings.
  • Invariants that held under attack: failover never fires on a content failure anywhere else (stream-frame in-band errors, empty streams, status ordering); secret_safety re-stamped after the last append on every path (35 probes); exit codes / degraded / primary_failed_over consistent; streaming fails over only before the first delta with the real category on the ledger; config/CLI parsing edge cases.
  • Base-vs-head regression matrix (7b79128 vs head, 20–50 scenarios, full normalized model_dump + call log + stream events + cache decision): every difference is one of the two documented changes — the new debate_final / judge receipt (+ derived redacted_errors), and the cache no-store rule.

🤖 Generated with Claude Code

@ernestprovo23
ernestprovo23 marked this pull request as ready for review September 3, 2026 12:42
@ernestprovo23
ernestprovo23 merged commit 7edb3a1 into main Sep 4, 2026
6 checks passed
@ernestprovo23

Copy link
Copy Markdown
Member Author

Merge receipt (release-control, security-specific). Approval received in the authenticated Claude Code chat from the repository owner on 2026-09-04: APPROVE merge DataScience-EngineeringExperts/conclave PR #63 at b2d7006a5eeba0416796c077e0dd2b01a284a7fa → main. Head SHA verified equal to the approved SHA immediately before merge; CI green; classification security-specific (vocabulary rule + SECURITY.md scope note; no security control changed — guarded functions byte-identical to main). Merged via release_control.py merge --method squash7edb3a141b33bfdecbedfb2e010993ce266b5aa4.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant