# Prune gate — fail-closed exit-code map, push-before-prune, halt-on-ambiguity (excerpt)

_Source: `system/_operator/scripts/prune-session.mjs` (header ~L74–84; reachability HALTs ~L443–461 and
~L584–600). No redaction required — framework orchestration logic, no secrets or customer data._

## The exit-code map (`EXIT`, frozen, ~L76–84)

Every prune failure mode fail-closes with a **named, distinct exit code** — the mechanical opposite of
the pre-2026-05-13 "WARN and exit 0" behaviour.

| Code | Constant | Meaning |
| --- | --- | --- |
| `0` | `SUCCESS` | pruned successfully |
| `1` | `GENERIC` | generic halt (missing/malformed `execute-trace.json`; assertion failure) |
| `2` | `LOCK_CONTENTION` | another gate holds the lock |
| `30` | `PRUNE_READINESS_REACHABILITY_FAILURE` | a session-touched submodule SHA is **not reachable** from a pushed remote ref |
| `31` | `PRUNE_WORKTREE_REMOVE_FAILURE` | `git worktree remove --force` failed |
| `32` | `PRUNE_BRANCH_DELETE_FAILURE` | worktree removed but `git branch -d` failed (idempotent on retry) |
| `33` | `PRUNE_GITMODULES_BRANCH_MISSING` | detection HALT — missing `.gitmodules` branch line, non-origin remote, or unreachable `toSha` |

The header states the binding rules directly (verbatim): "Every failure mode fail-closes with a named
exit code (I77)"; and "HALT-not-fallback on `.gitmodules` branch-line absence (I78)."

## Push-before-prune is the gate's first substantive phase

The header's internal-phase list orders the work so that **nothing destructive runs until reachability
is proven**:

```
1. Lock
2. Load promotion-request (assert status ∈ {promoted_local, pushed_github})
3. Live gitlink diff (detection library re-run at prune-time)
4. Reachability verification (cross-reference execute-trace push_reachability_report)
5. Worktree remove (fail-closed; exit 31 on failure)
6. Branch delete (fail-closed; exit 32 on failure; idempotent on retry)
7. Update promotion-request (pruned: true)
8. Invoke deploy gate
```

Phase 4 runs **before** Phase 5's `git worktree remove`. For each live-detected submodule the gate:
- requires a matching row in `push_reachability_report.per_submodule`;
- requires `row.recorded_sha === entry.toSha` (the pushed SHA is exactly the one detected);
- requires `row.reachable_post_push === true`;
- then performs a **live `ls-remote --exit-code origin refs/heads/<branch>`** and asserts the remote
  head is ancestor-or-equal of the detected `toSha`.

Any entry that fails any check is collected into `unreachable[]`; a non-empty `unreachable[]` writes a
`prune-readiness-failure.json` sidecar (including a `retry_push_command`) and returns
`EXIT.PRUNE_READINESS_REACHABILITY_FAILURE` (30) — **without removing the worktree** (~L584–600). The
un-pushed commit stays physically present on the worktree branch, recoverable.

## Halt-on-ambiguity (exit 33, ~L443–461)

The prune gate re-runs the canonical detection library (`detectSessionTouchedSubmodulesFromGitlinkDiff`)
at prune-time. If that library throws any of the three ambiguity classes —
`SubmoduleBranchMissingError`, `SubmoduleRemoteInvariantViolation`, or
`SubmoduleGitlinkUnreachableLocally` — the gate writes a `halt-detection-failure.json` sidecar and
returns `EXIT.PRUNE_GITMODULES_BRANCH_MISSING` (33). There is no default-to-`main`, no inference, no
silent skip: an ambiguous reachability picture is a **hard halt** for explicit operator resolution, not
a guess. This is the direct structural fix for the incident's root cause, where a submodule commit on a
non-`main` branch with incomplete branch metadata was pruned as though it had been published.
