Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
110 changes: 110 additions & 0 deletions docs/design/review-divergence-sentinel.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
# Review successor-chain sentinel (issue #9905)

## Problem

The `/review` cross-round ledger tracks finding ids, carries, and
supersession, but nothing reads the _lineage_: when the fix for round N's
Critical produces round N+1's Critical on the same subsystem, every round
looks locally normal while the subsystem is diverging. On #9659 the
stop-round blocker-dating chain produced three generations of successor
Criticals (R9-1 → R10-2/3/4 → R11-4/R11-6 → R12-1/R12-2); recognizing the
divergence took a manual round-count analysis and cost four rounds of
patch-and-regress before the mechanism was deleted and the open-finding
count collapsed.

The convergence module's recurrence cluster already says "the file sees
findings again" — it cannot say "the fix closed one and the mechanism grew
another", because no round records the _closures_.

## Design

A deterministic lineage check inside `diagnoseConvergence`, fed by a
bounded closure list the ledger marker carries. No model judgement: it is
a walk over data the pipeline already writes.

### The marker carries one `closed` list

`Ledger` gains an optional `closed` array. An entry `{r, id, f}` records a
**Critical** that was open in the previous round's work list and is absent
from this round's: composing round `r` diffs the recovered list against the
built ledger and appends one entry per vanished Critical id. Suggestions
are not tracked — Critical churn is the signal. `fixed` and `superseded`
both read as closure; a positional diff cannot tell them apart, and the
advisory note does not need it to.

The list carries **one generation, no carry-forward**: the check at round
N reads closures at N (minted while composing) and N-1 (read back off the
previous marker), so older entries are dead bytes. Bounds, matching the
marker's "footnote, never a payload" contract:

- count: `LEDGER_MAX_CLOSED` (50) entries — one full work list — newest
kept;
- bytes: in the serializer's shed cascade the closures go **after** the
volume telemetry and **before** the anchor pair and the work list —
advisory data never costs a re-review or a ruling. Shedding them never
sets `dropped`: closures certify no range.

Minting is suppressed wherever absence from the posting set does not
mean "ruled fixed" — the same honesty legs the `openCriticals` gate
applies to the identical inference: an incomplete recovered work list
(`dropped`/rejected entries — a missing id may be truncation, not a
ruling), a diff-only round that could not rule, a round that publicly
answered "cannot tell" on a Critical, and a pure-foreign previous list
whose entries are a stranger's. Minting also closes on CLAIM identity,
not id identity: a claim the round re-posts under a re-minted id (a
regenerated gate Critical, a relocated entry) still stands. Thin
history stays silent rather than guesses.

`parseLedger` validates closures through `isLedgerClosure` (caps and the
round bound, mirrored from the serializer); `prevLedgerFacts` carries them
into compose through the same admission test on the side-file route.

### The check (K = 2, current round included)

Inside `diagnoseConvergence`, beside the recurrence cluster: a file F
fires a **successor chain** when

- F closed a Critical in the previous round (`prev.closed` with
`r === round - 1`), and
- F closes a Critical this round (`closuresThisRound` with `r === round`),
and
- this round posts a **fresh** Critical on F (the built ledger's
`R<round>-*` ids — the built id's round IS the first-reported round,
after readback and admission).

The cluster's own file rules apply unchanged: the `(body)`/`(unknown)`
stand-ins never participate, and the `k` flag still separates a real path
spelled like one.

Checked against the #9659 evidence: composing round 11 closes R10-2/3/4
(r=11) on top of R9-1's closure at r=10, while R11-4/R11-6 land on the
same file — the note fires at round 11, one round before the manual
analysis caught the rebound.

### Output (advisory, first-class)

The note never moves the event and never withholds the anchor:

- `ConvergenceDiagnosis.successorChains` carries the typed chains
(`{file, generations, newIds}`), rendered by `renderSuccessorChain` as
`R9-1 → R10-2/R10-3 → R11-4`;
- the convergence observation (posted body + the stderr `CONVERGENCE:`
line) leads with the ⚠️ Divergence sentence naming the subsystem and
the chain, bilingually, `mdField`-escaped like every other
PR-controlled segment;
- `recommendationsFor` emits a new closed-vocabulary code,
`successor-chain`, with the chain in its `basis` — the machine-readable
half the autofix tooling reads. It rides the composed artifact through
the existing `recommendations` validation, which checks codes against
the runtime list.

### What it deliberately does not do

- It cannot distinguish `fixed` from `superseded` or a dropped carry —
closure is positional. The note is advisory, so a rare false positive
costs one sentence; the carry-forward machinery (`LEDGER_ID_READBACK`,
the stray-id rules) is what keeps carries honest.
- It never caps the verdict and never withholds the anchor. A divergence
is a property of the code under review, not of what this round read.
- File-level matching only; the ledger records no symbols (the issue's
"symbol, if recorded" — it is not recorded).
Loading
Loading