# Failure Routing and Seat Escalation Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use /ship (recommended) or /executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Make a seat binding an ordered array so a repeated failure escalates to the next model instead of re-burning the same one, route every failure class deliberately, and give the deterministic tier-0 auto-fix layer its registry seam ahead of any LLM dispatch.

**Architecture:** `seats.js` gains an array-aware `resolveSeat` and a `buildFixLadder(preset, tier)` that returns the per-task rung list; `quality.js` replaces its `RUNGS` constant with that ladder, stamps it into the journal so a resume cannot be rebound by an edited preset, and gains a keep-vs-revert rule driven by red-check attribution; `lib/gates.sh` starts emitting a machine-readable red-check summary that `gate.js` surfaces as `redChecks`; a new `v2/autofix.js` holds the tier-0 registry seam (entries are a separate brainstorm).

**Tech Stack:** Node 22 CommonJS (`node:test`), bash 5 (`lib/gates.sh`), JSON Schema draft 2020-12 preset validation.

**Spec:** `docs/specs/2026-08-07-failure-routing-and-escalation-design.md` — canonical. Every task's contract traces to a numbered section there.

**Tree boundary — MANDATORY:** touch only `modules/harness/v2/**`, `modules/harness/lib/gates.sh`, `modules/harness/spec/presets.schema.json`, `modules/harness/presets/**`, `modules/harness/test/**`. **NEVER `modules/harness/src/`** — untracked, undeployed, absent from every bundle. Editing it is invisible at runtime.

**Bundle note:** landing does not deploy. `bin/harness-release.sh bump`/`release` is post-run main-thread work and is deliberately **not** a task here.

---

## Wave Plan

| Wave | Tasks | Files touched | Safe to parallelize? |
|------|-------|---------------|----------------------|
| 1 | Task 1, Task 2, Task 3, Task 4 | `lib/gates.sh` · `spec/presets.schema.json`+`presets/_validate.mjs` · `v2/seats.js`+`v2/test/seats.test.js` · `v2/autofix.js`+`v2/test/autofix.test.js`+`v2/test/index.js` | ✅ no overlap |
| 2 | Task 5, Task 6 | `v2/gate.js`+`v2/test/gate.test.js` · `v2/run.js`+`v2/test/run-quality.test.js` | ✅ no overlap |
| 3 | Task 7 | `v2/quality.js`+`v2/test/quality.test.js` | single task |
| 4 | Task 8, Task 9 | `v2/quality.js`+`v2/test/quality.test.js` · `v2/run.js`+`v2/test/run.test.js` | ✅ no overlap |
| 5 | Task 10 | `v2/quality.js`+`v2/test/quality.test.js` | single task |

`v2/quality.js` is serialized across waves 3 → 4 → 5 by real file ownership, never "to be safe". `v2/run.js` is owned by Task 6 (wave 2) and Task 9 (wave 4) — different waves, never concurrent.

---

## File Structure

| File | Responsibility | Task |
|---|---|---|
| `modules/harness/lib/gates.sh` | emit a machine-readable gate summary when asked | 1 |
| `modules/harness/spec/presets.schema.json` | admit an array seat value | 2 |
| `modules/harness/presets/_validate.mjs` | validate array seat entries against the adapter catalog | 2 |
| `modules/harness/v2/seats.js` | array-aware seat resolution + fix-ladder construction | 3 |
| `modules/harness/v2/autofix.js` | **new** — tier-0 deterministic auto-fix registry + runner | 4 |
| `modules/harness/v2/gate.js` | surface `redChecks` from the gate summary | 5 |
| `modules/harness/v2/run.js` | verify-fixer chain walk (6); escalation ceiling + base-gate halt (9) | 6, 9 |
| `modules/harness/v2/quality.js` | ladder walk + stamp (7); keep/revert + scope routing (8); tier-0 invocation (10) | 7, 8, 10 |

---

## Testing note — engine resolution

`v2/test/gate.test.js` builds its fixture with `copyHarnessFixture`, which copies **this checkout's** `lib/gates.sh` into a scratch root and passes it as `harnessRoot`. It therefore already tests the checkout, not the bundle — no `HARNESS_ENGINE_DEV` needed. **Any acceptance command that shells out to `runplan` or resolves `lib/gates.sh` through `lib/engine-root.sh` MUST export `HARNESS_ENGINE_DEV=1` scoped to that subprocess** (`HARNESS_ENGINE_DEV=1 <command>`, never a bare `export` that leaks). No task below needs it; a task that adds such a command must add it.

---

## Task 1: Gate red-check summary

**Wave:** 1
**Blocks:** Task 5
**Blocked by:** —

**Files:**
- Modify: `modules/harness/lib/gates.sh` — `run_discovered_checks` (`:389-434`) and the `gate0` entry path write a summary file when the caller asks for one
- Test: `modules/harness/v2/test/gate.test.js` is Task 5's; this task's own check is the executable command below

**Contract (pin EXACTLY):**
- Trigger: environment variable `HARNESS_GATE_SUMMARY` holding an absolute path. **Not a positional argument** — `gates.sh` ships inside the engine bundle and a positional addition would be silently swallowed by an older copy.
- Unset/empty ⇒ behavior byte-identical to today. No file, no extra output, same exit codes.
- Set ⇒ on every gate0 termination (green or red) write that path atomically (temp file + `mv`) with exactly this shape:

```json
{"green":false,"red":["typecheck"],"checks":[{"name":"typecheck","code":2,"failClass":"check-failed","tailPath":"/abs/path/to/tail"}]}
```

- `green`: boolean. `red`: array of failing check names. `checks`: one record per check that ran to a verdict.
- `failClass` is the same value already computed into `FAILCLASS=` on the fail line (`check-failed` | `infra` | `flaky-check` | `gate-stalled` | `scope-violation`).
- `tailPath` is the value `gate0_write_output_tail` already returns.

**Behavior:**
- gate0 is **fail-fast**: `run_discovered_checks` exits on the first failing check (`:409`). `red` therefore normally holds exactly one name. Keep the array shape — do not change fail-fast semantics to collect more.
- A check that passed after `retry_check_isolated` or `retry_vitest_timeouts_isolated` recovered it is recorded in `checks` with `code: 0` and is **not** in `red`.
- The `no check commands found FAILCLASS=infra` fail-closed path (`:400`) still writes a summary: `{"green":false,"red":[],"checks":[]}` plus `"failClass":"infra"` at the top level.
- Writing the summary MUST NOT be able to change gate0's exit code. A failed write logs to stderr and is otherwise ignored.

**Acceptance (one executable check):**
- Run: `cd modules/harness && bash test/gate-summary.sh`
- Expected: exit 0, prints `gate-summary: ok` — the script creates a scratch repo with one passing and one failing check, runs `gates.sh gate0 strict` under `HARNESS_GATE_SUMMARY`, and asserts both summaries parse and carry the expected `green`/`red` values, plus one run with the variable unset asserting no file appears.

- [ ] Write `modules/harness/test/gate-summary.sh` covering the behavior above
- [ ] Run it, verify it fails against the current `gates.sh`
- [ ] Implement the summary writer
- [ ] Run it, verify pass
- [ ] Commit: `git add modules/harness/lib/gates.sh modules/harness/test/gate-summary.sh && git commit -m "feat: emit machine-readable gate0 red-check summary"`

---

## Task 2: Array seat value in schema and validator

**Wave:** 1
**Blocks:** Task 3
**Blocked by:** —

**Files:**
- Modify: `modules/harness/spec/presets.schema.json` — `seats.additionalProperties.oneOf` (`:22-26`) admits a third form
- Modify: `modules/harness/presets/_validate.mjs` — `validatePresetWrapperPaths` (`:504`), `validateTierCoverage` (`:414`), `collectDeclaredTiers` (`:464`), `isBinding`/`isTierMap` (`:701-707`) handle the array form
- Test: `modules/harness/presets/_validate.mjs` `runSelfTest` (`:89`) — extend, it is the validator's own suite

**Contract (pin EXACTLY):**
- Schema `$defs` gains:

```json
"bindingChain": {
  "type": "array",
  "minItems": 1,
  "maxItems": 8,
  "items": { "oneOf": [{ "$ref": "#/$defs/binding" }, { "$ref": "#/$defs/tieredBinding" }] }
}
```

- `seats.additionalProperties.oneOf` becomes `binding | tieredBinding | bindingChain`.
- A chain element may itself be a `tieredBinding`; tier selection applies per element.

**Behavior:**
- Every element of a chain is validated exactly as a standalone seat value: wrapper path exists (`validateWrapperPath`), model is present in the adapter catalog (`validateBindingCatalogReference`), `fallback` shape valid.
- Tier coverage (`validateTierCoverage`): a chain element that is a `tieredBinding` must declare every tier the preset declares anywhere, same rule as today. A chain element that is a bare `binding` is tier-agnostic and exempt, same as a bare seat today.
- `collectDeclaredTiers` walks chain elements so a tier declared only inside a chain still counts.
- Error messages name the element index: `<preset>: seats.fixer[2] ...`.
- A 9-element chain is rejected by the schema with the standard `maxItems` message. A 0-element chain is rejected by `minItems`.
- `presets/*.json` are **not** edited by this task — every shipped preset stays on the scalar form and must keep validating unchanged.

**Acceptance (one executable check):**
- Run: `cd modules/harness && node presets/_validate.mjs --self-test && node presets/_validate.mjs`
- Expected: both exit 0. Self-test includes new cases: a 3-element chain accepted; a 9-element chain rejected naming `maxItems`; a chain element with an unregistered model rejected naming `seats.fixer[1]`.

- [ ] Extend `runSelfTest` with the three new cases
- [ ] Run `node presets/_validate.mjs --self-test`, verify the new cases fail
- [ ] Implement schema + validator changes
- [ ] Run both commands, verify pass
- [ ] Commit: `git add modules/harness/spec/presets.schema.json modules/harness/presets/_validate.mjs && git commit -m "feat: accept an ordered binding chain as a seat value"`

---

## Task 3: Array-aware seat resolution and the fix ladder

**Wave:** 1
**Blocks:** Task 6, Task 7
**Blocked by:** —

**Files:**
- Modify: `modules/harness/v2/seats.js` — `resolveSeat` (`:67`) currently rejects any array seat outright; add `buildFixLadder` and `resolveIndependentFixBinding`
- Test: `modules/harness/v2/test/seats.test.js`

**Contract (pin EXACTLY):**

```
resolveSeat(preset, seatName, tier, presetName, rungIndex?) -> binding
buildFixLadder(preset, tier, presetName) -> { rungs: Rung[], warnings: string[] }
resolveIndependentFixBinding({ preset, ladder, burnedBindings }) -> binding | null

Rung := { id: string, seat: string, index: number, binding: object | null }
```

- `resolveSeat` with an array seat and no `rungIndex` resolves element 0. With `rungIndex: n` it resolves element n; out of range throws `preset <name>: seat "<seat>" has <len> bindings, rung <n> is out of range`.
- `buildFixLadder().rungs` is `['dependency-repair'] ++ chainOf(preset, 'fixer', tier)`.
- Rung ids are the literal strings `dependency-repair`, then `fixer#1`, `fixer#2`, … `fixer#N` (1-based).
- `Rung.seat` is the coarse seat name — `dependency-repair` or `fixer` — never the rung id. It is what `dispatch({ phase })` receives.
- `chainOf` back-compat, **exact**: a bare `seats.fixer` plus a `seats.stronger-fixer` (or `strongerFixer`) seat yields a 2-element chain `[fixer, stronger-fixer]`, so the ladder is `['dependency-repair','fixer#1','fixer#2']` — today's rung count and today's bindings.
- An array `seats.fixer` **wins** over any `stronger-fixer` seat; `buildFixLadder` returns a `warnings: string[]` sibling (see below) rather than throwing.
- `resolveIndependentFixBinding` returns the `dependency-repair` binding only when it differs in `provider` **or** `model` from every binding in `burnedBindings`; otherwise `null`. Same comparison `resolveIndependentReviewer` (`:171`) uses via `bindingIdentity`.

**Return shape for warnings:** `buildFixLadder` returns `{ rungs: Rung[], warnings: string[] }`. Callers read `.rungs`.

**Behavior:**
- A 1-element chain produces a 2-rung ladder (`dependency-repair`, `fixer#1`) — no escalation, which is the correct reading of "if there is only 1 model: no escalation possible".
- Both an array `fixer` and a `stronger-fixer` seat present ⇒ one warning string: `seat 'fixer' is an array; the deprecated 'stronger-fixer' seat is ignored`.
- Missing `fixer` seat entirely ⇒ ladder is `['dependency-repair']` only; that is today's `missing fixer binding` quarantine path, unchanged.
- A `dependency-repair` rung with no `resolver`/`dependency-repair` seat is still produced with `binding: null`; the caller quarantines exactly as today.
- `FALLBACK_FAILURE_KINDS` and `resolveBindingChain` are **untouched** — `fallback` (engine-down/timeout-repeated, same model class) stays orthogonal to escalation (quality failure, stronger model).

**Acceptance (one executable check):**
- Run: `cd modules/harness && node --test v2/test/seats.test.js`
- Expected: PASS — including `buildFixLadder` on a bare-`fixer`+`stronger-fixer` preset returning ids `['dependency-repair','fixer#1','fixer#2']`; on a 4-element array returning 5 rungs; a 1-element array returning 2 rungs; `resolveSeat(..., rungIndex: 9)` throwing the out-of-range message; `resolveIndependentFixBinding` returning `null` when the resolver binding matches a burned one.

- [ ] Write tests covering the behavior above
- [ ] Run them, verify they fail
- [ ] Implement `resolveSeat` array handling, `buildFixLadder`, `resolveIndependentFixBinding`
- [ ] Run tests, verify pass
- [ ] Commit: `git add modules/harness/v2/seats.js modules/harness/v2/test/seats.test.js && git commit -m "feat: resolve array seats and build a per-task fix ladder"`

---

## Task 4: Tier-0 auto-fix registry seam

**Wave:** 1
**Blocks:** Task 10
**Blocked by:** —

**Files:**
- Create: `modules/harness/v2/autofix.js` — deterministic pre-LLM repair registry and runner
- Create: `modules/harness/v2/test/autofix.test.js`
- Modify: `modules/harness/v2/test/index.js` — register the new test file (`suite-manifest.test.js` fails otherwise)

**Contract (pin EXACTLY):**

```
AutoFix := {
  id: string,
  safety: 'safe' | 'review' | 'unsafe',
  idempotent: true,
  matches(redChecks, gateSummary, changedFiles) -> boolean,
  apply(worktree, files) -> Promise<{ changed: string[] }>,
}

runAutoFix({ worktree, gateSummary, allowedFiles, registry? }) -> Promise<AutoFixRun>
AutoFixRun := { applied: { id: string, safety: string, files: string[] }[], changed: string[] }

registerAutoFix(entry: AutoFix) -> void      // throws on a rejected entry
```

**Behavior:**
- `runAutoFix` with `gateSummary` null/undefined, or `gateSummary.red` empty, returns `{ applied: [], changed: [] }` without touching the worktree.
- Only entries with `safety === 'safe'` are applied. `review` and `unsafe` entries are never executed by `runAutoFix` — they exist so the registry can carry them for a future human-gated mode.
- `apply` is called with the intersection of the entry's candidate files and `allowedFiles` (the task's `files_modify`). A file outside `allowedFiles` is never passed and never written.
- Entries run in registration order; each is applied at most once per call (`idempotent: true` is asserted at registration, not re-run to prove it).
- `changed` is the deduplicated union of every applied entry's changed paths.
- **Registration rejects, with a thrown `Error` naming the entry id**, any entry whose declared paths or glob touches: the repo's gate configuration, any test file, or `.warnignore`. A tier-0 fix that edits the thing being measured is indistinguishable from cheating.
- Registration also rejects a missing `id`, a duplicate `id`, `idempotent !== true`, or a `safety` outside the three literals.
- **The registry ships EMPTY.** The catalogue of concrete entries is a separate brainstorm (mine the transcript corpus for recurring agentic mistakes, then triage each safe/unsafe). This task builds the seam and its guards only — do not invent entries.

**Acceptance (one executable check):**
- Run: `cd modules/harness && node --test v2/test/autofix.test.js v2/test/suite-manifest.test.js`
- Expected: PASS — including: empty registry returns `{applied:[],changed:[]}`; a fixture `safe` entry runs and reports its changed files; a fixture `unsafe` entry never runs; an entry whose file list escapes `allowedFiles` is passed only the allowed subset; registering an entry declaring `.warnignore` throws naming the id; `suite-manifest` sees `autofix.test.js` registered.

- [ ] Write tests covering the behavior above
- [ ] Run them, verify they fail
- [ ] Implement `v2/autofix.js` and register the test file in `v2/test/index.js`
- [ ] Run tests, verify pass
- [ ] Commit: `git add modules/harness/v2/autofix.js modules/harness/v2/test/autofix.test.js modules/harness/v2/test/index.js && git commit -m "feat: add the tier-0 deterministic auto-fix registry seam"`

---

## Task 5: Surface red checks from the gate

**Wave:** 2
**Blocks:** Task 7, Task 8, Task 10
**Blocked by:** Task 1

**Files:**
- Modify: `modules/harness/v2/gate.js` — `runRealGate` (`:28`) passes the summary path and reads it back; `runStubGate` (`:8`) returns the same field shape
- Test: `modules/harness/v2/test/gate.test.js`

**Contract (pin EXACTLY):**
- `runRealGate` resolves to the existing child result **plus** two fields:

```
{ ...childResult, green: boolean, redChecks: string[] | null }
```

- `redChecks` is `[]` when green, an array of failing check names when red, and **`null` when no summary file was produced**.
- The summary path is generated per invocation under the run's log directory and passed to `gates.sh` as the environment variable `HARNESS_GATE_SUMMARY`. `runChild` gains no new positional argv.
- `runStubGate` returns `green` derived from its exit code and `redChecks: []` when green, `['stub']` when red — the stub has no check names.

**Behavior:**
- `null` and `[]` are semantically distinct and must never be conflated: `[]` means "gate green", `null` means "attribution unavailable".
- **Version skew degrades, never crashes.** A `gates.sh` that ignores `HARNESS_GATE_SUMMARY` writes nothing; `runRealGate` returns `redChecks: null` and the run proceeds.
- A malformed or truncated summary file is treated as absent: `redChecks: null`, no throw.
- The summary file is removed after it is read.
- `gateSucceeded` (`quality.js:313`) keeps its current shape and semantics — this task adds fields, it does not change how green is decided.

**Acceptance (one executable check):**
- Run: `cd modules/harness && node --test v2/test/gate.test.js`
- Expected: PASS — including: a green real gate returns `green:true, redChecks:[]`; a red real gate returns the failing check's name in `redChecks`; a fixture whose `lib/gates.sh` is replaced by a stub that ignores the variable returns `redChecks:null` with the run still completing; a corrupt summary file yields `redChecks:null` and no throw.

- [ ] Write tests covering the behavior above
- [ ] Run them, verify they fail
- [ ] Implement the summary path plumbing and parse
- [ ] Run tests, verify pass
- [ ] Commit: `git add modules/harness/v2/gate.js modules/harness/v2/test/gate.test.js && git commit -m "feat: return red-check attribution from the gate"`

---

## Task 6: Verify-fixer loop walks the chain

**Wave:** 2
**Blocks:** —
**Blocked by:** Task 3

**Files:**
- Modify: `modules/harness/v2/run.js` — `verifyWithFixerRetries` (`:815`), which pins `fixerBinding(qualityPreset, 'fixer')` (`:825`) and loops `MAX_VERIFY_FIXER_ATTEMPTS = 2` (`:815`)
- Test: `modules/harness/v2/test/run-quality.test.js`

**Contract (pin EXACTLY):**
- `verifyWithFixerRetries` resolves its bindings from `buildFixLadder(qualityPreset, task.tier, presetName).rungs`, filtered to rungs whose `seat === 'fixer'`.
- Attempt N (1-based) uses `fixer#N`. The loop bound becomes `min(MAX_VERIFY_FIXER_ATTEMPTS, fixerRungs.length)` — `MAX_VERIFY_FIXER_ATTEMPTS` stays `2` and stays the ceiling.
- The existing `verify.retry` journal record gains `rung` (the rung id) and `binding` (the binding label).

**Behavior:**
- A 1-element `fixer` chain reproduces today's behavior exactly: 2 attempts, both on the same binding.
- A 3-element chain still makes at most 2 attempts — the chain never raises the verify cap. Rung 3 is unreachable here; that is correct and is not warned about in this loop (the ladder's own truncation warning, Task 7, covers preset/budget mismatch).
- No `fixer` seat at all ⇒ unchanged early return (`:826`).
- This loop does **not** write `fix.rung` records — the gate-fix ladder is the sole writer of that history.

**Acceptance (one executable check):**
- Run: `cd modules/harness && node --test v2/test/run-quality.test.js`
- Expected: PASS — including: a canary preset with a 2-element `fixer` chain, verify failing twice, journals `verify.retry` with `rung:"fixer#1"` then `rung:"fixer#2"` and dispatches two different bindings; a 1-element chain journals `fixer#1` twice.

- [ ] Write tests covering the behavior above
- [ ] Run them, verify they fail
- [ ] Implement the chain walk
- [ ] Run tests, verify pass
- [ ] Commit: `git add modules/harness/v2/run.js modules/harness/v2/test/run-quality.test.js && git commit -m "feat: escalate the verify fixer along the seat chain"`

---

## Task 7: Gate-fix ladder walk, stamp, and resume

**Wave:** 3
**Blocks:** Task 8, Task 9, Task 10
**Blocked by:** Task 3, Task 5

**Files:**
- Modify: `modules/harness/v2/quality.js` — delete the `RUNGS` constant (`:8`); rewrite `nextGateFixRung` (`:152`); rewrite the ladder walk in `runQualityPhase` (`:247-283`); change `reviseAfterReviewFail` (`:184`) and its two call sites (`:234`, `:273`); replace `fixerBinding` (`:297`) with ladder consumption
- Test: `modules/harness/v2/test/quality.test.js`

**Contract (pin EXACTLY):**

```
nextGateFixRung(events, identity, ladder) -> Rung | 'exhausted'
reviseAfterReviewFail({ ..., binding }) -> <existing return shape>
```

- The ladder is journaled **once per task identity, before the first `fix.rung` record**:

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

- Each rung dispatch journals:

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

- `chainSource` is the literal `"preset"` or `"truncated"`.
- Chain-longer-than-budget warns **once** and proceeds, never crashes, and journals:

```json
{"kind":"preset.chain-truncated","seat":"fixer","chain":5,"budget":3}
```

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

**Behavior — ladder walk:**
- `runQualityPhase` builds the ladder once via `buildFixLadder`, journals `fix.ladder`, then walks it. Every `buildFixLadder` warning is emitted once to stderr.
- `dispatch({ phase })` receives `rung.seat` (`dependency-repair` | `fixer`), never the rung id, so `buildPrompt` (`seats.js:216`) needs no new branch. **Intended consequence:** a preset that today quarantines with reason `"stronger-fixer failed"` now quarantines with `"fixer failed"`; update the assertions in `quality.test.js` accordingly. The rung id in `fix.rung` carries the precision the reason string used to imply.
- The `dependency-repair` rung resolves via `resolveIndependentFixBinding`. `null` ⇒ skip the rung and journal `{"kind":"fix.rung.skipped","task":"t7","rung":"dependency-repair","reason":"no binding independent of burned fixer bindings"}` instead of dispatching a duplicate.
- Existing behavior kept verbatim: the 2-attempt unauthorized-file restore inside a rung, the `"<rung> escaped file claims twice"` quarantine, `childSucceeded`'s reply-marker requirement, the `gatesGreen` re-check after each rung, and `quarantine` on exhaustion.

**Behavior — one ladder per task:**
- `reviseAfterReviewFail` gains a **required** `binding` parameter and never advances the chain and never writes `fix.rung`. The pre-ladder call site (`:234`) passes rung `fixer#1`'s binding; the in-ladder call site (`:273`) passes the rung currently being walked.
- Two independent walkers are forbidden: they would double the escalated dispatch count and interleave two writers into the positionally validated history.

**Behavior — resume:**
- `nextGateFixRung` resolves its expected ladder from the journaled `fix.ladder` record for that identity, **never from the current preset**. A preset edit, a bundle flip, or `runplan --preset <other>` at resume time cannot rebind a half-walked ladder.
- History records are matched positionally against the stamped `rungs`; a rung id absent from the stamp or out of order still throws `'gate-fix history is malformed'` and quarantines, as today.
- Legacy journals are identified by the **absence of `of`** on their `fix.rung` records — never by the absence of a stamp. Such a history replays against the legacy names `['dependency-repair','fixer','stronger-fixer']`, then 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.

**Acceptance (one executable check):**
- Run: `cd modules/harness && node --test v2/test/quality.test.js`
- Expected: PASS — including: a 4-rung ladder all-red produces exactly 4 gate-fix dispatches with `of:4`; kill after `fixer#2` then resume returns `fixer#3`; resume after replacing the preset's chain still returns `fixer#3` from the stamp with no quarantine; a legacy history whose records lack `of` resumes without quarantine; a 5-rung chain under a 3-attempt budget warns once, journals `preset.chain-truncated`, and the run proceeds; `reviseAfterReviewFail` writes no `fix.rung` record.

- [ ] Write tests covering the behavior above
- [ ] Run them, verify they fail
- [ ] Implement the ladder walk, stamp, resume, and revise-binding parameter
- [ ] Run tests, verify pass
- [ ] Commit: `git add modules/harness/v2/quality.js modules/harness/v2/test/quality.test.js && git commit -m "feat: walk a stamped per-task fix ladder"`

---

## Task 8: Keep-vs-revert and scope-violation routing

**Wave:** 4
**Blocks:** Task 10
**Blocked by:** Task 5, Task 7

**Files:**
- Modify: `modules/harness/v2/quality.js` — the ladder walk's per-rung workspace handling; `unauthorizedFiles` (`:360`) and `restoreWorkspace` (`:370`) callers
- Test: `modules/harness/v2/test/quality.test.js`

**Contract (pin EXACTLY):**
- The ladder hoists one additional snapshot — the **pre-rung-1** snapshot, taken before the first rung dispatches — and retains it for the whole walk. `workspaceSnapshot` is already taken per rung (`:252`); this is one more, outside the loop.
- Per-rung tree decision, evaluated top to bottom, **first match wins**:

| Condition | Action |
|---|---|
| Unauthorized files touched | restore those files only (existing behavior, unchanged) |
| `redChecks` is `null` on either rung | **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 |

- Journaled per rung: `{"kind":"fix.tree","task":"t7","rung":"fixer#3","action":"revert","prevRed":["typecheck"],"red":["typecheck","test:unit"]}`. `action` is `"keep"` or `"revert"`.

**Behavior:**
- Revert targets the **pre-rung-1 snapshot** — the coder's committed output — and never the task base. A 20k-LOC diff with one missing bracket is therefore never rewritten from scratch; the next rung inherits the near-green tree.
- Keeping is the default. Revert fires only on divergence: a growing red set, or two consecutive rungs with no reduction.
- `null` `redChecks` (no gate summary — Task 5) takes the keep branch unconditionally and MUST NOT be read as "unchanged set", which would revert every rung on a version-skewed bundle.
- **Scope-violation routing:** a second unauthorized-file escape within one rung currently quarantines with `"<rung> escaped file claims twice"`. It now routes to the `dependency-repair` seat instead — one dispatch, with the escaped paths in its repair context — and quarantines only if that also escapes. Journal `{"kind":"fix.scope-routed","task":"t7","rung":"fixer#2","files":["…"]}`. The restore itself is unchanged: unauthorized files are always reverted first.
- The `dependency-repair` dispatch used for scope routing does **not** consume a ladder rung and writes no `fix.rung` record.

**Acceptance (one executable check):**
- Run: `cd modules/harness && node --test v2/test/quality.test.js`
- Expected: PASS — including: red set grew ⇒ `fix.tree` action `revert`; rung 1→2 unchanged ⇒ `keep`; rung 2→3 unchanged ⇒ `revert`; the reverted tree equals the pre-rung-1 snapshot and not the task base; `redChecks:null` on either side ⇒ `keep`; a canary arm escaping file claims twice journals `fix.scope-routed` and dispatches the resolver seat before any quarantine.

- [ ] Write tests covering the behavior above
- [ ] Run them, verify they fail
- [ ] Implement the snapshot hoist, the precedence table, and scope routing
- [ ] Run tests, verify pass
- [ ] Commit: `git add modules/harness/v2/quality.js modules/harness/v2/test/quality.test.js && git commit -m "feat: keep or revert the fix tree on red-set divergence"`

---

## Task 9: Run-level escalation ceiling and defective-gate halt

**Wave:** 4
**Blocks:** —
**Blocked by:** Task 7

**Files:**
- Modify: `modules/harness/v2/run.js` — the run loop around `plan-start` (`:106`) and the between-task boundary
- Test: `modules/harness/v2/test/run.test.js`

**Contract (pin EXACTLY):**
- Run-scoped counter of **escalated dispatches**: any `fix.rung` record whose rung id is `fixer#N` with `N ≥ 2`.
- Ceiling: `Math.ceil(taskCount / 2)`, where `taskCount` is the value already reported at `plan-start` (`:106`). Overridable per run via `plan.meta.escalation_ceiling` (positive integer); absent ⇒ the default.
- Reaching the ceiling halts the run **between tasks only** — an in-flight task always finishes its ladder. Journal:

```json
{"kind":"run.escalation-ceiling","count":7,"ceiling":6,"tasks":12}
```

- Halt message on stderr, verbatim: `halt: escalation ceiling reached (7 escalated dispatches, ceiling 6) — this is systemic, not one hard task; check the gate, the plan, and adapter health`

**Behavior — ceiling:**
- It is a smoke alarm, not a spending limit. A healthy run never approaches it: most tasks pass first try and tier 0 absorbs mechanical failures for free.
- It never truncates a task mid-ladder and never changes a task's own outcome.

**Behavior — defective gate:**
- Before wave 1, gate0 runs once against the integration base. Green ⇒ proceed silently.
- Red ⇒ halt immediately with a message naming both causes and refusing to guess between them, verbatim: `halt: gate0 is red on an untouched base — either the gate is defective or the base is broken. No task can turn this green; fix the gate or the base, then re-run.`
- Journal `{"kind":"gate.defective","base":"<rev>","redChecks":[…]}` using the `redChecks` field Task 5 added.
- If the base gate was green at run start and the base later goes red, the halt instead names the poisoning task: `halt: gate0 went red on the integration base after task <id>`.
- **A red gate is never excused.** There is no baseline-red pass-through and no flag to add one — a gate that passes while red is not a gate.

**Acceptance (one executable check):**
- Run: `cd modules/harness && node --test v2/test/run.test.js`
- Expected: PASS — including: a canary run of 4 tasks with a forced ceiling of 1 halts after the task that crossed it, with the in-flight task's ladder complete and `run.escalation-ceiling` journaled; a run whose base gate is red halts before wave 1 with the two-cause message and `gate.defective` journaled; a run whose base goes red mid-run halts naming the task.

- [ ] Write tests covering the behavior above
- [ ] Run them, verify they fail
- [ ] Implement the counter, the ceiling halt, and the base-gate check
- [ ] Run tests, verify pass
- [ ] Commit: `git add modules/harness/v2/run.js modules/harness/v2/test/run.test.js && git commit -m "feat: halt the run on a defective gate or systemic escalation"`

---

## Task 10: Tier-0 invocation before the ladder

**Wave:** 5
**Blocks:** —
**Blocked by:** Task 4, Task 5, Task 7, Task 8

**Files:**
- Modify: `modules/harness/v2/quality.js` — invoke `runAutoFix` before the first rung dispatch and after each red re-check
- Test: `modules/harness/v2/test/quality.test.js`

**Contract (pin EXACTLY):**
- `runQualityPhase` calls `runAutoFix({ worktree, gateSummary, allowedFiles: task.files_modify || [] })` from `v2/autofix.js` **before** dispatching any rung, whenever the gate is red and `redChecks` is a non-empty array.
- On a non-empty `changed`, journal `{"kind":"autofix.applied","task":"t7","id":"<entry id>","safety":"safe","files":["…"]}` — one record per applied entry — then re-run **only** the checks named in `redChecks` before deciding whether a rung is needed at all.
- Green after tier 0 ⇒ the quality phase succeeds having consumed **zero rungs and zero dispatches**.

**Behavior:**
- `redChecks === null` (no gate summary) ⇒ tier 0 is skipped entirely; there is nothing to match on and nothing to re-run selectively.
- The registry ships empty, so today this is a no-op on every real run. It is wired now so the separate catalogue brainstorm lands entries into a working seam rather than building the plumbing twice.
- Tier 0 never runs against files outside `task.files_modify`; `runAutoFix` already enforces that and this call site passes the claim list.
- Tier 0 runs before **every** rung, not only the first — a rung that leaves a newly-mechanical failure should be absorbed for free rather than escalating.
- A `runAutoFix` throw is caught, journaled as `{"kind":"autofix.failed","task":"t7","error":"<message>"}`, and the ladder proceeds. Tier 0 must never be able to fail a task.

**Acceptance (one executable check):**
- Run: `cd modules/harness && node --test v2/test/quality.test.js`
- Expected: PASS — including: with a fixture `safe` entry registered that turns the red check green, the quality phase completes with zero `fix.rung` records and one `autofix.applied`; with an empty registry the ladder runs exactly as in Task 7; `redChecks:null` produces no `autofix.*` record at all; a throwing entry journals `autofix.failed` and the ladder still runs.

- [ ] Write tests covering the behavior above
- [ ] Run them, verify they fail
- [ ] Implement the invocation, selective re-check, and error containment
- [ ] Run tests, verify pass
- [ ] Commit: `git add modules/harness/v2/quality.js modules/harness/v2/test/quality.test.js && git commit -m "feat: run deterministic auto-fix before any fixer dispatch"`

---

## Incident resolution final security blockers — 2026-08-12

**Status:** DONE

**Outcome:** Resolution learning consumes deployed taxonomy authority, resolution and learning resume durably after any phase, verified evidence owns authoritative summaries, and list filters run before pagination.

**Source request:** TDD-fix four confirmed blockers in `/home/user/Projects/overdeck/.worktrees/incidents-179-233-234`; preserve all WIP/security/routing fixes; no commit, deploy, or subagents.

**Acceptance:**
- Exact `{_meta, types}` taxonomy fixture passes through canonical `parseTaxonomy`.
- Resolution phases persist as verified → projected → closed → learned; crash/retry after each phase converges without duplicate/conflicting effects; learning never publishes before confirmed task closure.
- Resolution metadata/comment/KB authoritative summary derives from verified artifact evidence. Optional operator summary is stored separately as an untrusted note and cannot override evidence.
- Query and priority filters select from the full scoped result before offset/limit; a match beyond item 50 is returned.
- Focused tests, collector typecheck, and scoped Git diff check pass cleanly.

**Preserved WIP/ref/path:** Existing uncommitted work on `wt/incidents-179-233-234` at `/home/user/Projects/overdeck/.worktrees/incidents-179-233-234`; branch is 381 commits behind `origin/main`; do not rebase, reset, restore, commit, deploy, or alter unrelated files.

**Constraints:** Minimal robust code; TDD RED before production edits; retain existing final security fixes and routing restoration.

**Execution:** Add focused RED tests for each blocker; add durable resolution phase record to existing SQLite mutation store; reorder service phases and filtering; run focused GREEN tests; run collector typecheck; inspect scoped diff.

**Current receipt:** Implemented all four blockers. `resolution-learning.ts` now uses canonical `parseTaxonomy` against exact deployed `{_meta, types}` shape and excludes operator text from KB authority. Resolution evidence is journaled in SQLite and advances under the existing lease through `verified → closed → learned`; status projection confirms task closure before learning publication; identical retries resume idempotently and conflicting evidence is rejected. Verified artifact `howSolved` owns resolution metadata/comment; optional operator text is stored only as `overdeck.resolution_operator_note`. Incident query/priority filtering now precedes offset/limit. Focused receipt: `bun test src/incidents/resolution-learning.test.ts src/incidents/incident-service.test.ts src/incidents/incident-mutation-store.test.ts src/incidents/status-writer.test.ts` → 70 pass, 0 fail. `bun run typecheck` → clean. `git diff --check` → clean. Existing unrelated WIP remains present; no commit, deploy, rebase, reset, restore, or subagent occurred.

**Next executable action:** None within owner scope; hand off exact receipts and unchanged-WIP warning.

---

## Full-suite gate

After wave 5, the whole engine suite must be green — the ladder rewrite touches shared journal replay:

```
cd modules/harness && node --test v2/test/index.js
```

Expected: PASS, no skips beyond the pre-existing systemd/full-gate guards.

---

## Failure-class routing — reference

Spec §7 is the canonical map. Restated here as the acceptance surface for the tasks above; no task "implements the table" as a unit.

| Class | Route | Task |
|---|---|---|
| `gate-failed` | tier 0, then the fix ladder | 7, 10 |
| scope violation (unauthorized files) | restore, retry constrained, then `dependency-repair` | 8 |
| `empty-diff` | quarantine — no plan-level exception exists, verification is an op | — (unchanged) |
| `engine-down`, `timeout-repeated` | existing `fallback` chain; the rung index does not advance | — (unchanged) |
| `quality-stale-tree`, `workspace-provision-failed` | existing provisioning path | — (unchanged) |
| `budget-exceeded`, `budget-exhausted`, `stop-loss` | existing retry ledger; the ladder never overrides it | 7 (warn only) |
| gate red on an untouched base | halt as defective — never excused | 9 |
| systemic escalation across tasks | run-level ceiling halt | 9 |
