# Failure routing and escalation — design

audience: AI coding agents first.

**Goal:** every task failure is routed to the cheapest mechanism that can resolve it, and a seat that fails escalates along an operator-authored chain instead of a fixed three-name ladder.

**Scope:** `modules/harness/v2/**`, `modules/harness/lib/gates.sh`, `modules/harness/presets/**`, `modules/harness/spec/presets.schema.json`.

**Codebase note — read before touching anything.** The live engine is `modules/harness/v2/` (tracked; deployed as a bundle under `~/.harness/engine/versions/<ver>/`). `modules/harness/src/` is untracked, undeployed, and absent from every bundle — **it is not the engine and must not be edited by this work**. Per `modules/harness/CLAUDE.md`, editing `presets/*.json` or `wrappers/*.sh` in this checkout changes nothing at runtime; changes ship only when `bin/harness-release.sh bump` cuts a bundle and `release` flips `CURRENT`. Iterate with `HARNESS_ENGINE_DEV=1`.

---

## 1. What already exists (do not re-implement)

The gate-fix ladder in `v2/quality.js` is already rung-structured and already durable. Three properties are correct today and MUST be preserved, not rebuilt:

- **Rung ladder.** `RUNGS = ['dependency-repair', 'fixer', 'stronger-fixer']` (`v2/quality.js:8`); `fixerBinding(preset, rung)` (`:297`) maps each rung name to a seat name (`resolver`|`dependency-repair`, `fixer`, `stronger-fixer`|`strongerFixer`).
- **Durable rung position.** `nextGateFixRung(events, identity)` (`:152`) replays `fix.rung` journal events for the same task identity and returns the next unclaimed rung. A restart resumes mid-ladder; it does **not** restart at rung 1. The v1 defect recorded in `.audit-slop-prevention/autonomy-audit-2026-07-19.md:72-73` is already fixed in v2.
- **Seat independence.** `resolveIndependentReviewer` (`v2/seats.js:171`) refuses a reviewer binding sharing the coder's provider+model.

The defect is not durability. It is that the ladder length is **fixed at three and keyed by seat name**, so escalation depth is a code constant rather than an operator choice.

An inconsistency to fix along the way: `reviewerBindings` (`quality.js:292`) already accepts an **array** for `seats.reviewer`, but `resolveSeat` (`v2/seats.js:67`) rejects any array seat outright. A preset with an array reviewer half-works today.

---

## 2. The routing ladder

Four tiers, cheapest first. A failure enters at tier 0 and advances only when the tier below cannot resolve it.

| Tier | Mechanism | Cost | Consumes a rung? |
|---|---|---|---|
| 0 | Deterministic auto-fix registry | free | no |
| 1 | `dependency-repair` rung (environment) | one dispatch | its own rung |
| 2 | Seat escalation chain (`fixer`, or any seat) | one dispatch per rung | yes |
| 3 | Quarantine → human | — | — |

Tier 1 stays exactly where it is in the ladder: environment repair is not capability escalation, and it keeps its own dedicated rung ahead of the chain.

---

## 3. Seat binding arrays — the escalation primitive

### Schema

`spec/presets.schema.json` — every value under `seats` becomes:

```
seatValue := binding | tieredBinding | binding[]      // array: minItems 1, maxItems 8
```

`binding` and `tieredBinding` are unchanged. The array is ordered weakest→strongest and is that seat's escalation chain. It applies to **all** seats; the seat enum stays open.

- **Length 1 ⇒ no escalation.** Semantically identical to a bare binding.
- **Length N ⇒ N−1 escalations.**
- A `tieredBinding` inside an array resolves against the task's tier before the chain is walked, so a chain may mix explicit and tiered entries.

`resolveSeat` (`v2/seats.js:65`) gains array handling and a `rungIndex` parameter; with no index it returns element 0, which is what every non-escalating caller (`seatDispatch`, `resolveReference`) wants. `reviewerBindings` collapses onto the same normalization instead of carrying its own ad-hoc array branch.

### `fallback` stays orthogonal

`fallback` is an **availability** mechanism: `FALLBACK_FAILURE_KINDS = new Set(['engine-down','timeout-repeated'])` (`v2/seats.js:10`), enforced by `resolveBindingChain` (`:137`) and `fallbackEligible` (`v2/run.js:914`). Escalation is a **capability** mechanism. A rung may carry its own `fallback`; using it never advances the chain. `MAX_BINDING_HOPS = 4` continues to bound fallback depth independently of chain length.

### Model identifiers

Chain entries are `binding` objects naming models by full adapter id. `presets/adapters.json` is the registry; a model absent from it cannot be bound by any seat. It currently enumerates `gpt-5.6-{sol,terra,luna}-{low,medium,high}` — **there is no `-xhigh` id**, so an `xhigh` rung is not expressible until the registry gains one. That registration is out of scope here. A chain naming an unregistered id MUST fail preset validation (`presets/_validate.mjs`) at load, before any run state exists.

### Replacing the fixed ladder

`RUNGS` stops being a constant and becomes a per-task computed ladder:

```
buildFixLadder(preset, tier) -> rung[]
  rung := { id: string, seat: string, index: number, binding }

  ['dependency-repair'] ++ chainOf(preset, 'fixer', tier)
```

Rung ids: `dependency-repair`, then `fixer#1`, `fixer#2`, … `fixer#N`.

**Backward compatibility is exact.** When `seats.fixer` is a bare binding and `seats.stronger-fixer` exists, `chainOf` returns `[fixer, stronger-fixer]` — the ladder is `['dependency-repair','fixer#1','fixer#2']`, byte-for-byte today's behavior and today's dispatch count. Every shipped preset keeps working with zero edits. `stronger-fixer` becomes a deprecated alias for a 2-element `fixer` chain; the preset validator warns when both an array `fixer` and a `stronger-fixer` seat are present, and the array wins.

The `fix.rung` journal record gains `of` (ladder length), `seat`, and `chainSource`:

```json
{"kind":"fix.rung","task":"t7","phase":"gate-fix","rung":"fixer#2","of":4,"seat":"fixer","chainSource":"preset","binding":"…"}
```

`chainSource` is `"preset"` or `"truncated"` (retry budget shorter than the chain, §"Chain longer than the retry budget") — an operator reading one rung record must not have to correlate a separate `preset.chain-truncated` line to know why the ladder ended early.

### Resume: the ladder is stamped, not recomputed

`nextGateFixRung` (`quality.js:152`) today validates replayed history positionally against the module constant `RUNGS`, and throws `'gate-fix history is malformed'` on mismatch — a throw the caller (`:238`) turns into a **quarantine**. With `RUNGS` frozen in code, replay can never disagree. With a preset-derived chain it can: an edited preset, a flipped bundle, or a plain `runplan --preset <other>` at resume time changes the chain under a half-walked ladder. Recomputing on resume would silently rebind `fixer#2` to a different model, or return `exhausted` for a chain that was never walked, and quarantine the task.

**The chain is stamped once, at task start, and replay reads the stamp — never the current preset.** First entry into the gate-fix ladder for a task identity journals:

```json
{"kind":"fix.ladder","task":"t7","rungs":["dependency-repair","fixer#1","fixer#2","fixer#3"],"bindings":["…","…","…","…"],"chainSource":"preset"}
```

`nextGateFixRung(events, identity)` resolves its expected ladder from that record and validates the `fix.rung` history against it. Consequences, all required:

- A preset change mid-run does **not** alter an in-flight task's ladder. The stamp wins for the life of the task.
- Only a genuinely corrupt history (a rung id absent from the stamp, or out of order) still throws and quarantines. A benign preset edit never does.
- **Ordering is pinned:** `fix.ladder` MUST be journaled before the task's first `fix.rung`. Legacy records are identified by the **absence of `of`**, never by the absence of the stamp — a crash between the stamp write and the first rung write must not be mistaken for a pre-upgrade journal.
- A history whose records lack `of` is a pre-upgrade run: replay falls back to the legacy `['dependency-repair','fixer','stronger-fixer']` names, stamps the computed ladder, and continues. Old journals resume; they do not quarantine.
- A record carrying `of` with **no** preceding `fix.ladder` stamp is a corrupt journal — it throws and quarantines, as today.

`dispatch({ phase: rung })` currently passes the rung name as the phase. Phase stays the coarse name (`dependency-repair` | `fixer`), so `buildPrompt` (`v2/seats.js:216`) needs no new branch; the rung id travels alongside.

### One ladder per task — the other two fixer call sites

There are three places a fixer binding is resolved. Exactly **one** of them owns the ladder.

1. **Gate-fix ladder** (`quality.js:247-283`) — the owner. It walks the chain and is the only writer of `fix.rung` history.
2. **`reviseAfterReviewFail`** (`quality.js:184`) — currently pins `fixerBinding(preset, 'fixer')` and loops `MAX_REVIEW_REVISE_ATTEMPTS = 2`. It is called both *before* the ladder (`:234`) and *inside* it (`:273`). It **MUST NOT advance the chain.** Its signature gains a required `binding` parameter supplied by the caller: the pre-ladder call site passes rung `fixer#1`; the in-ladder call site passes the rung currently being walked. It never journals `fix.rung`.
3. **Verify-fixer loop** (`run.js:815,825`, `MAX_VERIFY_FIXER_ATTEMPTS = 2`) — walks the same chain: attempt N uses rung N, capped at chain length. A 1-element chain gives today's behavior.

Rationale, stated so it is not re-derived: a task escalates along **one** ladder. Letting review-revise walk its own chain would double the escalated dispatch count per task and would interleave two writers into the `fix.rung` history that `nextGateFixRung` validates positionally.

### Phase strings

`dispatch({ phase: rung })` (`quality.js:256`) passes the rung name as the dispatch phase, and that string reaches both `buildPrompt` (`v2/seats.js:216`, which branches on `dependency-repair` | `fixer` | `stronger-fixer`) and the quarantine reason `"<rung> failed"` (`:257`).

Phase stays the coarse **seat name** (`dependency-repair` | `fixer`); the rung id (`fixer#2`) travels beside it and appears only in journal records. Consequence, intended and explicit: a preset that today reaches the `stronger-fixer` rung quarantines with reason `"stronger-fixer failed"`, and after this change quarantines with `"fixer failed"`. `quality.test.js` assertions on that string are updated; the rung id in `fix.rung` carries the precision that the reason string used to imply.

### Chain longer than the retry budget

The retry ledger (`retry.stop-loss`, `v2/run.js:579`) caps attempts independently. When the ladder is longer than the budget allows, the tail is unreachable. This **warns once and proceeds** — never crashes, never truncates the preset:

```
warn: seat 'fixer' chain has 5 rungs but the retry ledger allows 3 — rungs 4-5 unreachable this run
```

Journaled as `preset.chain-truncated` with seat, chain length, and budget.

### Independent fix binding

The `dependency-repair` rung resolves from `seats.resolver`. If that binding equals a `fixer` chain rung that has already burned on this task, the ladder wastes a dispatch on a known-dead seat. Add `resolveIndependentFixBinding`, mirroring `resolveIndependentReviewer`: the resolved binding MUST differ in provider or model from every binding already journaled as burned for this task identity. No distinct candidate ⇒ skip the rung and journal `fix.rung.skipped` with the reason, rather than dispatching a duplicate.

---

## 4. Red-check attribution — required plumbing

`runRealGate` (`v2/gate.js:28`) invokes `lib/gates.sh gate0 strict …` and surfaces **only an exit code**. `gatesGreen` (`quality.js:287`) passes `phase:'exact'` and `check: failedCheck`, but `runRealGate` ignores both. So the engine cannot currently tell *which* checks are red.

`lib/gates.sh` already computes exactly that: it runs discrete check records and re-runs each red check isolated to confirm attribution (`lib/gates.sh:268`).

**Required change:** `gates.sh gate0` writes a machine-readable summary to a caller-named path:

```json
{"green":false,"red":["typecheck","test:unit"],"checks":[{"name":"typecheck","code":2,"logTail":"…"}]}
```

The summary path is passed as a **named env var** (`HARNESS_GATE_SUMMARY`), not a 7th positional argument — `runRealGate`'s argv is positional and `lib/gates.sh` ships inside the bundle, so a positional addition would be silently swallowed by an older script.

`runRealGate` reads it and returns `{ code, green, redChecks }`. `gateSucceeded` (`quality.js:313`) keeps its current shape; new consumers read `redChecks`.

**Version skew — the summary is OPTIONAL, and its absence degrades, never crashes and never reverts.** A `gates.sh` that does not honor `HARNESS_GATE_SUMMARY` writes no file. Then:

- `redChecks` is `null` (distinct from `[]`, which means "green").
- Tier 0 is **skipped** for that gate result — there is nothing to match on.
- The keep/revert rule (§5) cannot compare red sets, so it takes the **keep** branch unconditionally. `null` MUST NOT be read as "unchanged set" — that would revert every rung on a skewed bundle.
- A single `gate.summary.missing` journal record per run says so.

`gatesGreen` makes two gate calls (`phase:'exact'`, then `phase:'strict'`). Each writes its own summary; **`redChecks` comes from the FIRST call that returned non-green** — `exact` when exact is red, `strict` when exact passed and strict failed. A green run has `redChecks: []`.

Both tier 0 (which checks to re-run) and the keep/revert rule (§5) depend on this. It is the first thing to build.

---

## 5. Keep-vs-revert after a failed rung

Today `restoreWorkspace` (`quality.js:370`) reverts **only unauthorized files** — files outside `task.files_modify`, via `unauthorizedFiles` (`:360`). A fixer that edits authorized files and still leaves the gate red keeps its edits, and the next rung inherits them. Keeping is the right default and stays the default; what is missing is a divergence cutoff.

Revert targets **the workspace snapshot taken before rung 1** — `before = workspaceSnapshot(worktree)` at the top of the ladder, i.e. the coder's output. Reverting to the task base would discard the coder's entire task and is never correct. A 20k-LOC diff with one missing bracket is therefore never rewritten: rung 2 always inherits the near-green tree.

Precedence — evaluate top to bottom, first match wins:

| Condition | Action |
|---|---|
| Unauthorized files touched | restore those files only (existing behavior, unchanged) |
| `redChecks` is `null` on either rung (no gate summary — §4) | **keep** |
| Red-check set **grew** vs. the previous rung | revert to the pre-rung-1 snapshot |
| Rung 1 → 2 | **keep** |
| Rung ≥ 2 and the set **shrank** on the previous rung | **keep** |
| Rung ≥ 2 and the set was **unchanged** on the previous rung | revert to the pre-rung-1 snapshot |

Two consecutive rungs with no reduction is divergence; the next rung restarts from the coder's output rather than compounding a wrong direction.

Journaled per rung: `{"kind":"fix.tree","task":"t7","rung":"fixer#3","action":"revert","prevRed":[…],"red":[…]}`.

This requires the ladder to retain the pre-rung-1 snapshot for its whole run. `workspaceSnapshot` is already taken per rung (`quality.js:252`); hoist one additional snapshot outside the `while` loop.

---

## 6. Tier 0 — deterministic auto-fix registry

Only the **seam** is specified here. The catalogue of entries is a separate brainstorm.

New module `v2/autofix.js`.

### Contract

```
AutoFix = {
  id: string,                                   // stable key, journaled
  matches(redChecks, gateSummary, changedFiles) -> boolean,
  apply(worktree) -> Promise<{ changed: string[] }>,
  safety: 'safe' | 'review' | 'unsafe',
  idempotent: true,                             // MUST hold; re-running yields no further change
}

runAutoFix({ worktree, gateSummary, allowedFiles }) -> { applied: AutoFixResult[], changed: string[] }
```

`safety` is **required** and closed-enum — part of the registry schema, not the catalogue:

- `safe` — auto-applies. Semantically inert transforms (whitespace, em-dash → hyphen in non-code text, import order, lockfile resync from an unchanged manifest).
- `review` — applies; the diff is journaled and surfaced in the run report.
- `unsafe` — **never auto-applies.** Detection only; a match becomes a hint injected into the next rung's `repairContext`.

### Behavior

1. Runs after a red gate, before any dispatch, and again before each rung.
2. Applies every matching `safe` and `review` entry, restricted to `task.files_modify` — an entry that would touch a file outside the claim is skipped and journaled, reusing the existing claim discipline rather than inventing a second one.
3. Re-runs only `redChecks` (§4). Green ⇒ the task proceeds with **no rung consumed and no dispatch billed**.
4. Still red ⇒ applied edits are kept (inert by construction) and the rung proceeds.
5. Journals `{"kind":"autofix.applied","task":"t7","id":"…","safety":"safe","files":[…]}`. Zero matches journals nothing.

**Registry load rejects** any entry whose declared paths touch gate configuration, test files, or `.warnignore`. Tier 0 must never turn a gate green by weakening the gate.

---

## 7. Failure-class routing map

Complete over the failure classes v2 actually emits. **tier-0** = a deterministic entry may resolve it; **chain** = consumes a rung; **env** = the `dependency-repair` rung; **block** = task blocked/quarantined for a human.

| Failure class | tier-0 | env | chain | block | Notes |
|---|:--:|:--:|:--:|:--:|---|
| `gate-failed` | ✅ | ✅ | ✅ | on exhaustion | the main path; full ladder |
| `acceptance-failed` | ✅ | | ✅ | | verify loop, walks the same chain |
| `quality-quarantined` | | | | ✅ | ladder already exhausted |
| `quality-stale-tree` | | | | ✅ | worktree mutated under the engine — never auto-repair |
| `empty-diff` | | | ✅ | | always a failure — see §8 |
| `claim-exceeded` | | | ✅ | | existing 2-attempt constrained retry runs first |
| `scope-violation` (gates.sh) | | | ✅ | | same as `claim-exceeded` |
| `dispatch-invalid-reply` | | | ✅ | | missing reply marker; the seat produced nothing usable |
| `dispatch-failed` | | ✅ | ✅ | | |
| `engine-down` | | | **no** | | `fallback` only — MUST NOT consume a rung |
| `timeout-repeated` | | | **no** | | `fallback` only |
| `budget-exhausted` / `budget-exceeded` | | | **no** | ✅ | run/task budget — not a capability failure |
| `stop-loss` | | | | ✅ | retry ledger exhausted |
| `workspace-provision-failed` | | ✅ | | ✅ | env repair, then block |
| `infra` (gates.sh: no check commands) | | | | ✅ | fail-closed by design |
| `check-failed` (gates.sh) | ✅ | | ✅ | | maps to `gate-failed` |
| `coordinator` | | | | ✅ | engine fault |
| `gate-defective` *(new)* | | | | ✅ | run scope — see §8 |

A ✅ in both tier-0 and chain means tier 0 is attempted first and a rung is consumed only if tier 0 does not resolve it.

`engine-down`, `timeout-repeated`, and budget classes MUST NOT advance the chain: the seat never failed on merit. `fallbackEligible` (`v2/run.js:914`) already gates this correctly for fallback; the chain walker MUST honor the same predicate.

---

## 8. Two policy positions, stated so they are not re-opened

### Baseline red is never excused

There is no "the gate was already red, continue anyway" path. A gate that passes while red is not a gate.

A gate red on an **untouched base** is therefore not an excuse-generator — it is a run-level diagnosis. New failure class `gate-defective`, scope run, outcome: halt the run. Either the gate is wrong and must be fixed, or it must be deleted; a red baseline is not a supported operating state.

The halt message MUST distinguish two causes, because the task base is the moving integration branch:

- Base commit is reachable from the run's **starting** base ⇒ *"the gate is defective or the repo is broken — fix or remove the gate."*
- Base commit was produced by **this run** ⇒ *"an earlier task in this run poisoned the integration branch — see task `<id>`."*

Same halt, different remediation. Sending the operator to fix a healthy gate is the failure mode this distinction exists to prevent.

### `empty-diff` has no plan-level exception

`empty-diff` is always a failure and always escalates a rung. No `allow_empty` flag.

- Documentation produces a diff.
- Verification is an operation. It may be non-writing, but it is not a no-op — and a non-writing check is a **deterministic gate**, not an agent seat.
- A task that is "a few bash commands" belongs in `lib/gates.sh`, not in a dispatch.

Consequence for plan authoring: a task that would legitimately produce no diff is mis-modeled and must be re-authored as a gate.

---

## 9. Decision observability

Every routing decision is journaled with its basis, not just its verdict — the ladder currently journals `fix.rung` with a binding label and nothing about *why* that rung was chosen or what it changed.

New and extended records:

```json
{"kind":"fix.ladder","task":"t7","rungs":["dependency-repair","fixer#1","fixer#2","fixer#3"],"bindings":["…"],"chainSource":"preset"}
{"kind":"fix.rung","task":"t7","rung":"fixer#2","of":4,"seat":"fixer","chainSource":"preset","binding":"…","reason":"gate-failed","redChecks":["typecheck"]}
{"kind":"gate.summary.missing","run":"…","note":"gates.sh did not honor HARNESS_GATE_SUMMARY; tier 0 disabled, tree kept every rung"}
{"kind":"fix.tree","task":"t7","rung":"fixer#3","action":"revert","prevRed":["typecheck"],"red":["typecheck","test:unit"]}
{"kind":"fix.rung.skipped","task":"t7","rung":"dependency-repair","reason":"no binding independent of burned fixer bindings"}
{"kind":"autofix.applied","task":"t7","id":"emdash-to-hyphen","safety":"safe","files":["docs/x.md"]}
{"kind":"preset.chain-truncated","seat":"fixer","chain":5,"budget":3}
```

`quarantine` (`quality.js:383`) already records a reason string; it gains the ladder actually walked (`rungs`, already collected) and the final `redChecks`, so a quarantine states which checks were never made green.

Surfaced in the run timeline and the `/plans` run view: one line per rung, expandable to the red-check set and the tree decision.

---

## 10. Run-level escalation ceiling

Per-task retry is already bounded by the retry ledger (`retry.stop-loss`). What is missing is a run-level view: nothing today notices that *every* task is escalating.

Add a run-scoped counter of escalated dispatches (rung index ≥ 2). Default ceiling `ceil(taskCount / 2)`, overridable per run.

This is a smoke alarm, not a spending limit. In a healthy run it is never approached — most tasks pass first try, tier 0 absorbs mechanical failures for free, and only genuinely hard tasks escalate. It trips when something systemic is wrong (a defective gate, a bad plan, a dead adapter making everything fail) and then **halts the run and reports**, instead of grinding every task through a full chain on a metered model.

**No task is ever abandoned mid-ladder.** The ceiling halts the run *between* tasks; a task that has started its ladder always finishes it. The report names the tasks that consumed the budget.

---

## 11. Data flow

```dot
digraph routing {
  rankdir=LR;
  gate  [label="gate red\n+ redChecks", shape=box];
  t0    [label="tier 0\nautofix.js", shape=box];
  re    [label="re-run redChecks", shape=diamond];
  dep   [label="dependency-repair\nrung", shape=box];
  chain [label="fixer chain\nrung N", shape=box];
  tree  [label="keep / revert", shape=diamond];
  more  [label="rung N+1\nexists?", shape=diamond];
  q     [label="quarantine → human", shape=doublecircle];
  ok    [label="task green", shape=doublecircle];

  gate -> t0 -> re;
  re -> ok    [label="green"];
  re -> dep   [label="still red, first pass"];
  dep -> chain;
  chain -> ok   [label="gate green"];
  chain -> tree [label="gate red"];
  tree -> more;
  more -> t0 [label="yes (next rung)"];
  more -> q  [label="exhausted"];
}
```

---

## 12. Testing

Existing suites extend rather than fork: `v2/test/seats.test.js`, `quality.test.js`, `run.test.js`, `task-budget.test.js`, `failure-fingerprint.test.js`. Loop mechanics use the `canary` preset (`wrappers/canary-stub.sh`) — free, offline, deterministic, no model-quality confound. Add chain arms to `canary-stub.sh`; never alter an existing arm.

| Area | Check |
|---|---|
| Schema | array seat of length 1 resolves identically to a bare binding; length 9 rejected; unregistered model id rejected by `_validate.mjs` |
| Back-compat | a preset with bare `fixer` + `stronger-fixer` produces ladder `['dependency-repair','fixer#1','fixer#2']` and the same dispatch count as today |
| No stacking | array `fixer` plus a `stronger-fixer` seat warns and uses the array only |
| Chain walk | 4-rung chain, all red ⇒ exactly 4 gate-fix dispatches, `fix.rung` reports `of:4` |
| Durability | kill mid-`fixer#2`, resume ⇒ `nextGateFixRung` returns `fixer#3`, never `dependency-repair` |
| Resume vs. preset change | kill mid-`fixer#2`, edit the preset chain (or relaunch `--preset <other>`), resume ⇒ ladder comes from the `fix.ladder` stamp, next rung is `fixer#3`, no quarantine |
| Resume, legacy journal | a `fix.rung` history with no `fix.ladder` stamp resumes on the legacy rung names and stamps the computed ladder; never quarantines |
| Gate summary skew | `gates.sh` writing no summary ⇒ `redChecks:null`, tier 0 skipped, every rung keeps its tree, one `gate.summary.missing` record, run completes |
| Independence | `seats.resolver` identical to a burned `fixer` rung ⇒ `fix.rung.skipped`, no duplicate dispatch |
| No rung burn | `engine-down` mid-rung ⇒ `fallback` fires, rung index unchanged |
| Warning | chain longer than the retry ledger warns once, run proceeds |
| Red-check plumbing | `runRealGate` returns `redChecks` matching `gates.sh` attribution; a two-red-check run reports both |
| Tree decision | grew ⇒ revert; rung 1→2 unchanged ⇒ keep; rung 2→3 unchanged ⇒ revert; revert target is the pre-rung-1 snapshot, never the base |
| Tier 0 | a matching `safe` entry turning the gate green consumes zero rungs and zero dispatches |
| Tier 0 safety | an `unsafe` entry never mutates the worktree; an entry declaring a path under gate config, tests, or `.warnignore` is rejected at load; an entry touching a file outside `files_modify` is skipped and journaled |
| Base-red | red gate on an untouched base halts with the *gate-defective* message; red on a run-produced base halts naming the poisoning task |
| Ceiling | ceiling reached ⇒ run halts between tasks; the in-flight task completes its ladder |

---

## Architecture Decisions

**Accepted collapses**

- *No new escalation-policy module.* Rung selection is seat resolution with an index. It belongs in `v2/seats.js` and the existing ladder walk in `v2/quality.js`, not a new seam.
- *Tier 0 is a separate module from the `dependency-repair` rung.* They look adjacent but differ on every axis: `dependency-repair` repairs the **environment** via an LLM dispatch after a failure has already cost a rung; tier 0 repairs the **diff** deterministically before anything is billed. Different trigger, target, cost, and safety model.
- *`buildFixLadder` keeps its own function (in `v2/seats.js`), it is not three lines inlined in `runQualityPhase`.* Deletion test: three call sites consume the chain — the gate-fix ladder, the verify-fixer loop (`run.js:815`), and the pre-ladder `reviseAfterReviewFail` call that needs rung 1's binding. Inlining scatters chain-normalization, the `stronger-fixer` alias, and the budget-truncation warning across two files. Depth: medium — callers see `rung[]` and never the seat-name aliasing behind it.
- *One ladder per task; `reviseAfterReviewFail` takes a binding parameter and never advances the chain.* Two independent walkers would double the escalated dispatch count and interleave two writers into the positionally-validated `fix.rung` history.
- *`stronger-fixer` is not kept as a first-class rung.* It is exactly a 2-element `fixer` chain. It stays as a recognized alias for back-compat and is deprecated in the schema.

**Rejected candidates**

- *Per-task cost circuit breaker* — rejected by the operator: "If agent is failed, so its failed." A task never stops mid-ladder; cost control lives at tier 0 (avoided dispatches) and the run-level ceiling (systemic-failure halt).
- *Baseline-red quarantine* — rejected: a gate that passes while red is not a gate.
- *`allow_empty` plan flag* — rejected: non-writing verification is a deterministic gate, not an agent seat.
- *Blanket revert after a failed rung* — rejected: it would discard the coder's work. Revert targets the pre-rung-1 snapshot only, and only on divergence.
- *Building against `modules/harness/src/`* — rejected: untracked, undeployed, absent from every bundle.

**Deferred to a separate brainstorm (operator-directed)**

- The tier-0 **catalogue**. Input is a mining pass over the transcript corpus (`~/.claude/projects`, ~9.9 GB) for recurring agentic mistakes, then per-category triage into the `safe` / `review` / `unsafe` classification this spec's registry schema requires. `slopgate` (`~/Projects/slopgate`, separate repo, active users) is a candidate vehicle for the text-slop entries and may gain a `--fix` mode landed to its origin/main and synced to the buildboxes. The seam above is fixed, so that brainstorm is pure content.
