Harness Rollback
Post-ship circuit breaker. Proposes a full-context revert PR when a shipped PR fails post-merge evaluation or crosses a signal threshold. v1 is propose-only — it NEVER auto-merges. A human merges the revert.
When to Use
- When a merged PR is suspected of causing a regression and you want a considered, full-context revert prepared for review.
- When a tracked signal (error rate, a baseline count, any
.harness/signals/ series) crosses a threshold and you want the implicated PR(s) evaluated for rollback.
- As the manual entry point to the same engine the scheduled
rollback-propose workflow drives automatically.
- NOT for reverting un-merged work (use
git/gh directly).
- NOT for deployment/infrastructure rollback — this operates at the git/PR layer (it opens a revert PR), not at the deploy layer.
- NOT to auto-merge a revert. v1 does not have that authority (see Iron Law).
Process
Iron Law
v1 never auto-merges a revert. It opens a revert PR and stops. A human decides.
Auto-merging code — even a revert — is a high-blast-radius write, and a wrong revert is itself an incident. The trust model earns auto-merge authority only after the propose loop has demonstrably proposed correct reverts over time (recorded via the rollback_event breadcrumb). Until then, the workflow carries pull-requests: write but not contents: write and no self-approving PAT. If you find yourself merging a revert automatically, STOP — that is a separate, deferred trust tier (see ADR 0063).
Phase 1: RESOLVE — Identify the target
- Take the target merged PR number (
--pr <n>) and the trigger (signal or eval).
- Resolve the PR's merge commit and changed files via
gh. If the PR is not merged (no merge commit), stop with a structured skipped decision — there is nothing to revert.
- Determine the merge shape: a two-parent merge commit reverts against parent 1 (
-m 1); a squash/rebase merge is single-parent and reverts against its sole parent. This repo uses both — never assume a two-parent merge.
Phase 2: CLASSIFY — Is it revert-ready?
Run classifyRevert (core). A target is revert-ready only when BOTH hold:
- Clean revert — an in-memory
git merge-tree --write-tree of the revert applies with no conflicts. (Never a working-tree-mutating git revert -n.)
- No dependent later merge — no PR merged after the target touches the same files. A later dependent merge →
action: 'blocked' (a naive revert would orphan newer work).
blastRadius and migrationWarnings are context only, never gates — they enrich the PR body so the human reviewer sees the stakes; they do not decide revert-readiness.
Phase 3: COMPOSE — Open the revert PR (or dry-run)
- If
--dry-run, print the PR body and stop — open no PR.
- Otherwise open a revert PR: title
revert: <original> (automated rollback), marker label harness:rollback, body = the full context block (trigger, target, revert-ready verdict, classification reasons, blast-radius, migration warnings, and the --reason if given).
- Idempotency: if an open PR labeled
harness:rollback already references the target (#<n>, word-boundary matched — #42 must not match #420), skip — do not open a duplicate.
Phase 4: RECORD — Breadcrumb
Append one rollback_event to .harness/signals/: { targetPr, trigger, revertReady, action, prUrl, reason, ts }. This append-only record is what later justifies (or refuses) the auto-merge trust tier — it is not backfillable, so it is written on every evaluation.
Triggers
- Signal arm (live): the scheduled
rollback-propose.yml workflow runs harness rollback sweep, which reads .harness/signals/timeline.json and, for each rollback.signals entry { threshold, direction, window }, detects an edge crossing, resolves the PR(s) merged in the window, and forwards each to evaluate --trigger signal.
- Eval arm (dark until outcome-eval runs post-merge): guarded by
rollback.evalTrigger.enabled (default false). When outcome-eval is wired to run post-merge, a high-confidence NOT_SATISFIED will route through the same engine with --trigger eval. Until then the path exists and is unit-tested but never fires — enabling it is a config flip, not a code change.
Harness Integration
harness rollback evaluate — the CLI core; classification + compose + breadcrumb for one target PR.
harness rollback sweep — the signal arm; timeline threshold detection → evaluate.
classifyRevert / RollbackDecision (@harness-engineering/core) — the pure, injected-IO classification engine.
.github/workflows/rollback-propose.yml — propose-only post-merge + scheduled workflow. contents: read + pull-requests: write, concurrency-serialized, no self-approving PAT.
harness.config.json → rollback — signals (record of { threshold, direction, window }) and evalTrigger.enabled.
- ADR 0063 — the post-ship rollback trust model (propose → auto-merge progression).
Success Criteria
- A revert PR is opened only for a revert-ready target (clean revert + no dependent later merge); non-clean/blocked/unmerged targets yield a structured
skipped/blocked decision and no PR.
- The revert PR body carries trigger, target, blast-radius, and migration warnings.
- Re-running against the same target opens no duplicate PR (label + word-boundary idempotency).
- Every evaluation appends one
rollback_event breadcrumb.
- No revert is auto-merged — a human merges the PR.
- The eval arm produces no PR while
rollback.evalTrigger.enabled is false.
Gates
- No auto-merge. v1 opens PRs; it never merges them. Enforced by the workflow's minimal permissions (no
contents: write, no self-approving PAT).
- No revert without revert-ready classification. A conflicting revert or a dependent later merge blocks the proposal.
- No two-parent assumption. Squash/rebase merges must revert against their sole parent.
- No working-tree mutation. Revert-readiness is tested in-memory (
merge-tree), never with git revert -n.
Rationalizations to Reject
| Rationalization |
Reality |
| "The eval clearly failed, so I should auto-merge the revert to stop the bleeding" |
v1 has no auto-merge authority. Open the PR; a human merges. Auto-merge is a deferred trust tier (ADR 0063). |
"git revert -n in a temp index is fine for the readiness check" |
-n still writes the working tree. Use git merge-tree --write-tree — pure in-memory, no side effects. |
"It's a merge commit, so -m 1 is safe" |
Squash/rebase merges are single-parent; -m 1 computes a meaningless revert. Check the parent count first. |
| "The signal fired, so every PR in the window should be reverted" |
Only revert-ready targets are proposed, and only the human merges. A crossing is a signal to evaluate, not to revert blindly. |
Examples
Example: Signal-threshold crossing proposes a revert
Context: harness.config.json sets rollback.signals.error-rate = { threshold: 50, direction: "above", window: "24h" }. The hourly rollback-propose workflow runs harness rollback sweep.
sweep reads .harness/signals/timeline.json → error-rate crosses 50 (edge, not plateau)
→ resolves PRs merged in the 24h window: #<pr>
→ evaluate --pr <pr> --trigger signal
RESOLVE: #<pr> merged, two-parent merge commit
CLASSIFY: merge-tree revert clean; no later merge touches its files → revert-ready
COMPOSE: opens "revert: Add tiered discount pricing (automated rollback)"
labeled harness:rollback, body carries trigger + blast-radius + migration warning
RECORD: appends rollback_event { targetPr: 758, trigger: signal, action: proposed, ts }
→ A human reviews and merges the revert PR. Autopilot never merges it.
Example: Blocked because a later merge depends on the target
Context: Manual invocation harness rollback evaluate --pr 740 --dry-run.
CLASSIFY: a later PR merged after the target and touches the same files (dependent merge)
→ revertReady: false, action: "blocked"
→ no PR opened; decision reports "a later merge depends on this PR's files"
Escalation
- The target PR is a squash/rebase merge: the engine reverts against its sole parent (no
-m 1). If parent resolution is ambiguous, stop with a skipped decision and surface the merge shape — do not guess.
git merge-tree exits non-zero for a reason other than conflict (e.g. 128 bad object): this is an error, not a conflict. Re-throw and surface it; do not report a false skipped.
- A crossing resolves to many PRs in the window: evaluate each independently; the composer's label idempotency prevents duplicate revert PRs. If the volume looks wrong (an unexpectedly wide window), check for date-truncation in the resolver before proposing.
- The human asks to auto-merge the revert: decline for v1 — that authority is the deferred Stage-2 trust tier (ADR 0063), gated on the
rollback_event track record. Open the PR and let a human merge.
rollback.evalTrigger.enabled is true but the post-merge invoker has not landed: the eval arm has no post-merge invoker yet, so it still won't fire; note the dependency rather than wiring a bespoke trigger.
1---2name: harness-rollback3description: Harness Rollback4---5# Harness Rollback67> Post-ship circuit breaker. Proposes a full-context **revert PR** when a shipped PR fails post-merge evaluation or crosses a signal threshold. v1 is **propose-only** — it NEVER auto-merges. A human merges the revert.89## When to Use1011- When a merged PR is suspected of causing a regression and you want a considered, full-context revert prepared for review.12- When a tracked signal (error rate, a baseline count, any `.harness/signals/` series) crosses a threshold and you want the implicated PR(s) evaluated for rollback.13- As the manual entry point to the same engine the scheduled `rollback-propose` workflow drives automatically.14- NOT for reverting un-merged work (use `git`/`gh` directly).15- NOT for deployment/infrastructure rollback — this operates at the git/PR layer (it opens a revert PR), not at the deploy layer.16- NOT to auto-merge a revert. v1 does not have that authority (see Iron Law).1718## Process1920### Iron Law2122**v1 never auto-merges a revert. It opens a revert PR and stops. A human decides.**2324Auto-merging code — even a revert — is a high-blast-radius write, and a wrong revert is itself an incident. The trust model earns auto-merge authority only after the propose loop has demonstrably proposed _correct_ reverts over time (recorded via the `rollback_event` breadcrumb). Until then, the workflow carries `pull-requests: write` but **not** `contents: write` and **no** self-approving PAT. If you find yourself merging a revert automatically, STOP — that is a separate, deferred trust tier (see ADR 0063).2526---2728### Phase 1: RESOLVE — Identify the target29301. Take the target merged PR number (`--pr <n>`) and the trigger (`signal` or `eval`).312. Resolve the PR's merge commit and changed files via `gh`. If the PR is **not merged** (no merge commit), stop with a structured `skipped` decision — there is nothing to revert.323. Determine the merge shape: a two-parent merge commit reverts against parent 1 (`-m 1`); a **squash/rebase** merge is single-parent and reverts against its sole parent. This repo uses both — never assume a two-parent merge.3334### Phase 2: CLASSIFY — Is it revert-ready?3536Run `classifyRevert` (core). A target is **revert-ready** only when BOTH hold:3738- **Clean revert** — an in-memory `git merge-tree --write-tree` of the revert applies with no conflicts. (Never a working-tree-mutating `git revert -n`.)39- **No dependent later merge** — no PR merged after the target touches the same files. A later dependent merge → `action: 'blocked'` (a naive revert would orphan newer work).4041`blastRadius` and `migrationWarnings` are **context only, never gates** — they enrich the PR body so the human reviewer sees the stakes; they do not decide revert-readiness.4243### Phase 3: COMPOSE — Open the revert PR (or dry-run)44451. If `--dry-run`, print the PR body and stop — open no PR.462. Otherwise open a revert PR: title `revert: <original> (automated rollback)`, marker label `harness:rollback`, body = the full context block (trigger, target, revert-ready verdict, classification reasons, blast-radius, migration warnings, and the `--reason` if given).473. **Idempotency:** if an open PR labeled `harness:rollback` already references the target (`#<n>`, word-boundary matched — `#42` must not match `#420`), skip — do not open a duplicate.4849### Phase 4: RECORD — Breadcrumb5051Append one `rollback_event` to `.harness/signals/`: `{ targetPr, trigger, revertReady, action, prUrl, reason, ts }`. This append-only record is what later justifies (or refuses) the auto-merge trust tier — it is not backfillable, so it is written on every evaluation.5253---5455## Triggers5657- **Signal arm (live):** the scheduled `rollback-propose.yml` workflow runs `harness rollback sweep`, which reads `.harness/signals/timeline.json` and, for each `rollback.signals` entry `{ threshold, direction, window }`, detects an edge crossing, resolves the PR(s) merged in the window, and forwards each to `evaluate --trigger signal`.58- **Eval arm (dark until outcome-eval runs post-merge):** guarded by `rollback.evalTrigger.enabled` (default `false`). When outcome-eval is wired to run post-merge, a high-confidence `NOT_SATISFIED` will route through the same engine with `--trigger eval`. Until then the path exists and is unit-tested but never fires — enabling it is a config flip, not a code change.5960## Harness Integration6162- **`harness rollback evaluate`** — the CLI core; classification + compose + breadcrumb for one target PR.63- **`harness rollback sweep`** — the signal arm; timeline threshold detection → `evaluate`.64- **`classifyRevert` / `RollbackDecision`** (`@harness-engineering/core`) — the pure, injected-IO classification engine.65- **`.github/workflows/rollback-propose.yml`** — propose-only post-merge + scheduled workflow. `contents: read` + `pull-requests: write`, concurrency-serialized, no self-approving PAT.66- **`harness.config.json` → `rollback`** — `signals` (record of `{ threshold, direction, window }`) and `evalTrigger.enabled`.67- **ADR 0063** — the post-ship rollback trust model (propose → auto-merge progression).6869## Success Criteria70711. A revert PR is opened only for a revert-ready target (clean revert + no dependent later merge); non-clean/blocked/unmerged targets yield a structured `skipped`/`blocked` decision and no PR.722. The revert PR body carries trigger, target, blast-radius, and migration warnings.733. Re-running against the same target opens no duplicate PR (label + word-boundary idempotency).744. Every evaluation appends one `rollback_event` breadcrumb.755. No revert is auto-merged — a human merges the PR.766. The eval arm produces no PR while `rollback.evalTrigger.enabled` is false.7778## Gates7980- **No auto-merge.** v1 opens PRs; it never merges them. Enforced by the workflow's minimal permissions (no `contents: write`, no self-approving PAT).81- **No revert without revert-ready classification.** A conflicting revert or a dependent later merge blocks the proposal.82- **No two-parent assumption.** Squash/rebase merges must revert against their sole parent.83- **No working-tree mutation.** Revert-readiness is tested in-memory (`merge-tree`), never with `git revert -n`.8485## Rationalizations to Reject8687| Rationalization | Reality |88| --------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- |89| "The eval clearly failed, so I should auto-merge the revert to stop the bleeding" | v1 has no auto-merge authority. Open the PR; a human merges. Auto-merge is a deferred trust tier (ADR 0063). |90| "`git revert -n` in a temp index is fine for the readiness check" | `-n` still writes the working tree. Use `git merge-tree --write-tree` — pure in-memory, no side effects. |91| "It's a merge commit, so `-m 1` is safe" | Squash/rebase merges are single-parent; `-m 1` computes a meaningless revert. Check the parent count first. |92| "The signal fired, so every PR in the window should be reverted" | Only revert-ready targets are proposed, and only the human merges. A crossing is a signal to _evaluate_, not to revert blindly. |9394## Examples9596### Example: Signal-threshold crossing proposes a revert9798**Context:** `harness.config.json` sets `rollback.signals.error-rate = { threshold: 50, direction: "above", window: "24h" }`. The hourly `rollback-propose` workflow runs `harness rollback sweep`.99100```101sweep reads .harness/signals/timeline.json → error-rate crosses 50 (edge, not plateau)102→ resolves PRs merged in the 24h window: #<pr>103→ evaluate --pr <pr> --trigger signal104 RESOLVE: #<pr> merged, two-parent merge commit105 CLASSIFY: merge-tree revert clean; no later merge touches its files → revert-ready106 COMPOSE: opens "revert: Add tiered discount pricing (automated rollback)"107 labeled harness:rollback, body carries trigger + blast-radius + migration warning108 RECORD: appends rollback_event { targetPr: 758, trigger: signal, action: proposed, ts }109→ A human reviews and merges the revert PR. Autopilot never merges it.110```111112### Example: Blocked because a later merge depends on the target113114**Context:** Manual invocation `harness rollback evaluate --pr 740 --dry-run`.115116```117CLASSIFY: a later PR merged after the target and touches the same files (dependent merge)118→ revertReady: false, action: "blocked"119→ no PR opened; decision reports "a later merge depends on this PR's files"120```121122## Escalation123124- **The target PR is a squash/rebase merge:** the engine reverts against its sole parent (no `-m 1`). If parent resolution is ambiguous, stop with a `skipped` decision and surface the merge shape — do not guess.125- **`git merge-tree` exits non-zero for a reason other than conflict (e.g. 128 bad object):** this is an error, not a conflict. Re-throw and surface it; do not report a false `skipped`.126- **A crossing resolves to many PRs in the window:** evaluate each independently; the composer's label idempotency prevents duplicate revert PRs. If the volume looks wrong (an unexpectedly wide window), check for date-truncation in the resolver before proposing.127- **The human asks to auto-merge the revert:** decline for v1 — that authority is the deferred Stage-2 trust tier (ADR 0063), gated on the `rollback_event` track record. Open the PR and let a human merge.128- **`rollback.evalTrigger.enabled` is true but the post-merge invoker has not landed:** the eval arm has no post-merge invoker yet, so it still won't fire; note the dependency rather than wiring a bespoke trigger.