> ## Documentation Index
> Fetch the complete documentation index at: https://docs.whyfile.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# 0028 c3 convergence gate

# ADR-0028: The C3 convergence gate

**Status:** Accepted
**Date:** 2026-07-11

## Context

ADR-0026 (C1) defined the panel roster and the pure `converge` function that collapses one node's
lens findings into a verdict envelope. ADR-0027 (C2) built the executor that runs the roster over
the real graph and emits raw per-node findings — `findings` plus a distinct `errored_lenses` — but
stops there: no convergence, no trust-state write, no gate. C3 is the remaining seam ADR-0026
named: read C2's raw output through `panel.converge`, record the panel's verdicts into the trust
log (`trust --set`'s own path, ADR-0021), and give CI something to block a merge on.

A hostile 5-lens panel reviewed the C3 plan before code was written (ADR-0022's own practice
applied to itself), running the query tools live rather than judging the plan on the page. It
caught three problems that reshaped the design:

* **Two human-verdict-override paths.** The original plan recorded every auto verdict
  unconditionally and resolved the gate from the affirmation ledger alone. Under that plan, an
  auto-recording run could silently overwrite a verdict a human had already set by hand (`trust --set ... --by reviewer`), and the gate would ignore that same human verdict as a valid
  resolution if it wasn't also present in the ledger. Both are the panel acting as a certifier over
  a human, which ADR-0021 forbids. The fix: the trust log's latest event per node is checked in
  both directions — `record_panel_verdicts` skips (`human_held`) any node whose latest event was
  written by a non-panel actor, and `--gate` treats a human-authored latest event as resolving,
  unioned with the affirmation ledger, not the ledger alone.
* **A systemic-outage gate hole.** The original convergence rule only used `refuted`/`survived`
  counts; a node where every applicable lens errored (backend outage, schema drift) but none
  refuted converged to `applicable == 0`, tier `none` — identical to a node no lens selected at
  all. A total backend outage would read as "nothing to report," and neither the report nor a gate
  built on it could tell "reviewed, clean" from "not reviewed." The fix is the **generalized
  degradation** rule below.

## Decision

**Convergence runs per audit via `panel.converge_report(nodes, policy=DEFAULT_CONVERGENCE)`.** It
is pure and never mutates its input: for each C2 node it calls `converge(findings, policy)`
(ADR-0026), then applies degradation, then folds the result back onto the node as `converged` and
into a summary the CLI merges into the report (`_run_audit` in `cli.py`).

**Generalized degradation: errored and not disputed becomes `degraded`.** If a node has any
`errored_lenses` and its converged envelope is neither `needs_human` nor already `trust.DISPUTED`,
`converge_report` overwrites the envelope to `{verdict: None, needs_human: True, tier: "degraded"}`
before counting it. This closes the full-error gate hole: a node where every lens errored no longer
converges to the same `none` tier as an untouched node — it surfaces as `degraded`, a distinct,
counted tier (`by_tier["degraded"]`) that says "the panel could not review this," not "this is
clean." A node that already disputed on other lenses keeps its `disputed` verdict even if some
lenses on it errored — degradation only overrides tiers with nothing to say, it never suppresses a
structural finding.

**`degraded` is an audit-report-only tier — it never enters `trust.VERDICT_STATES`.**
`trust.VERDICT_STATES` (`corroborated`, `confirmed`, `disputed`) is unchanged; `degraded` is not
addable to it and is never written to the trust log or resolved by `trust.resolve_state`. It exists
only in `converge_report`'s output so a human reading the audit report (or `--format markdown`)
sees incomplete coverage honestly, without the panel inventing a fourth trust state that C4's
lifecycle (ADR-0021) never defined and no other command needs to know about.

**The gate is ledger-aware and human-authority-absolute.** `--gate` computes `resolved =
human_resolved | affirmed`, where `human_resolved` is every node id whose *latest* trust-log event
was written by a non-panel actor (`trust.latest_events`, filtering `by != "panel"`) and `affirmed`
is every id in the affirmation ledger (E1). `panel.gate_unresolved(disputed_ids, resolved)` — pure
— returns the structural disputes not in `resolved`; a non-empty result exits `EXIT_POLICY` (5). A
node the panel disputed is never gate-blocking once a human has set *any* verdict on it or affirmed
it — the gate defers to the most recent human action, never re-litigates it, and `degraded` nodes
never gate-block at all (they are not in `disputed_ids`; the tier is a report signal, not a merge
blocker).

**`--record` never overwrites a human, and skips authored nodes.** `trust.record_panel_verdicts`
appends only `converge_report`'s `auto_verdicts` — nodes converged with a verdict, not
`needs_human`, and not `provenance: authored` (authored intent is already golden; the panel does
not judge it). For each candidate it looks at the node's latest trust-log event: a non-panel `by`
skips it (`human_held`), an unchanged state skips it (`unchanged`), otherwise it appends
(`recorded`, or `reversed` if it flips a prior *panel* verdict) via the same `trust.append_verdict`
`trust --set` uses — a panel event and a manual `--set` event share one shape, verified by a parity
test. `--record` is opt-in; a plain `whyfile audit` run never touches the trust log (verified by a
read-only-default test), matching the read-only default of every other query command.

**`EXIT_POLICY` (5) is reused, not redefined.** `errors.py`'s comment is generalized from
naming only `changed --fail-on-constraint` to naming every merge-blocking gate that shares the
code: `changed --fail-on-constraint`, `intent-diff --gate`, `check`, and now `audit --gate`. One
exit code, one meaning ("a gate a human needs to look at tripped"), across every subcommand that
gates a merge — an agent branching on exit codes gets one contract instead of one per subcommand.

## Assumptions

* We assume `panel.LENSES`'s five-lens roster (ADR-0026) stays the source of what "every
  applicable lens errored" means for degradation; if the roster grows or shrinks, degradation's
  behavior (proportional to whatever `errored_lenses` C2 reports) needs no code change, but is
  worth re-verifying (review by 2026-10-01).
* We assume a human's *latest* trust-log event is the right authority signal for both `--record`
  and `--gate` (rather than, say, requiring an explicit `--override` flag) — this bets that
  `trust --set --by <actor>` is rare enough, and deliberate enough, that "last write wins" does not
  need a confirmation step (expires: if panel and human verdicts are observed racing in practice,
  e.g. concurrent CI runs recording against the same log, this needs a lock or a merge policy).

## Alternatives considered

1. **Gate on `disputed` from `converge` directly, without ledger/human-event resolution.**
   Rejected by the hostile panel: a human who already reviewed and accepted a disputed node (via
   `trust --set ... --by reviewer` or `affirm`) would see the gate fail again on every subsequent
   run, since nothing marked the dispute as resolved. Ledger-aware resolution makes the gate
   idempotent once a human has acted.
2. **Fold `degraded` into `trust.VERDICT_STATES` as a fourth panel-writable state.** Rejected:
   ADR-0021's lifecycle is a promotion/demotion ladder a human affirmation can outrank; "the panel
   couldn't review this" is not a trust judgment about the node's content, it is a statement about
   review completeness, so it does not belong in the same state machine `resolve_state` uses to
   answer "what does the code currently trust."
3. **Treat a fully-errored node as `disputed` (fail closed) instead of `degraded`.** Rejected: a
   backend outage is not counter-evidence against the node's claim, and auto-recording a dispute
   from zero actual refutations would fabricate a verdict the panel never rendered — the same
   failure-inflation concern ADR-0027 raised for individual lens calls, generalized to the whole
   node.
4. **A dedicated exit code for `audit --gate` distinct from `EXIT_POLICY`.** Rejected: every other
   merge-blocking gate in this codebase already exits 5; a caller branching on exit codes should
   not have to special-case audit, and the comment fix in `errors.py` is the cheaper, honest
   alternative to a new code.

## Consequences

* A `whyfile audit --record --gate` run is safe to wire into CI today: it is read-only unless
  `--record` is passed, it never overwrites a human's own verdict, and a merge only blocks on a
  structural dispute no human has looked at yet — the exact bar ADR-0021 set for panel authority.
* `degraded` gives a reviewer (or an agent parsing `--format markdown`) an honest "the panel
  couldn't finish this" signal distinct from both "disputed" and "clean," closing the silent-outage
  gap the hostile panel found, without inventing a new trust state other commands must special-case.
* `converge_report` and `gate_unresolved` are pure functions of C2's raw findings plus the trust
  log/ledger, so the whole convergence-and-gate policy is unit-tested without a live backend — the
  same property ADR-0026 and ADR-0027 established for `converge` and the executor.
* Reusing `EXIT_POLICY` keeps the exit-code contract (ADR-0013) at five stable codes instead of
  growing one per gate, at the cost of the comment now needing to name every gate it covers instead
  of one.
