# Four-gate close/promote/prune/deploy contract (excerpt)

_Source: `system/_operator/docs/CLOSE_PROMOTE_DEPLOY_CONTRACT.md § 1` (four-gate doctrine, I75–I78) and
the four gate scripts under `system/_operator/scripts/`. No redaction required — framework
orchestration logic, no secrets or customer data._

The close-out workflow is **four separate command boundaries**, each its own script, its own lock, its
own preconditions, and its own fail-closed exit codes. The pipeline is (contract § 1):

```
work → close (paperwork + promotion request)
     → promote (inventory → canonicalize → execute → push)
     → prune (live detection → reachability → worktree-remove → branch-delete → request-update)
     → deploy
```

| Gate | Script | Owns | Must NOT contain |
| --- | --- | --- | --- |
| **Close** | `close-session.mjs` | preflight, paperwork pass, session commit, mints the promotion-request | Vercel logic; no merge/push/deploy — paperwork + promotion-request only |
| **Promote** | `promote-session.mjs` | single lock, inventory → canonicalize → execute → **push** of session-touched submodules | Vercel logic; `git worktree remove` / `git branch -d` tokens (C112 Dim A) |
| **Prune** | `prune-session.mjs` | the SOLE owner of `git worktree remove --force` and `git branch -d`; runs live gitlink detection + reachability verification first | `vercel` tokens; any "best-effort"/"non-fatal"/"WARN and continue"/"Manual cleanup:" framing |
| **Deploy** | `deploy-session.mjs` | the ONLY script that may invoke the Vercel CLI | — |

## Why each is a separate state

Prune is **destructive** — it removes the worktree and deletes the branch, which is where an
un-pushed submodule commit physically lives. Conflating it inside promote (the pre-2026-05-13 shape,
where promote's "Step 5.5" ran the prune) was the structural defect: burial made **"best-effort" the
natural framing** for a destructive step, so a prune failure logged a WARN and the pipeline exited 0
(from the owner plan's Objective and Defect D2/D3).

Splitting prune into its own gate makes four things enforceable that a buried step could not be:

- **Push-before-prune ordering (I75).** `prune-session.mjs` must verify every session-touched
  submodule SHA is reachable from a pushed remote ref **before any destructive operation**. The
  verification source is the detection library re-run at prune-time, cross-referenced with promote's
  `push_reachability_report`. `promote-session.mjs` MUST NOT spawn `prune-session.mjs` on Exit 21
  (`PARTIAL_SUCCESS`) or when `push_reachability_report.all_inputs_reachable` is false.
- **Boundary isolation (I76).** `git worktree remove` and `git branch -d` live exclusively in
  `prune-session.mjs`; promote may not contain them. Caliber C112 enforces. This mirrors I50, which
  confines the Vercel CLI to `deploy-session.mjs`.
- **Fail-closed semantics (I77).** Every prune failure mode has a **named exit code** (30/31/32/33)
  plus a sidecar JSON artifact. No advisory downgrade.
- **HALT-not-fallback (I78).** A `.gitmodules` entry missing its `branch = …` line for a
  session-touched submodule is a hard HALT (exit 33), never an inference to `main`.

Each gate is invoked as a **separate process**, not in-process — `close-session.mjs` spawns
`promote-session.mjs`, which (only on a clean, reachable result) hands off to `prune-session.mjs`,
which hands off to `deploy-session.mjs`. Separate processes mean each boundary re-establishes its own
lock and re-checks its own preconditions from committed state, so a later gate can never proceed on a
stale in-memory assumption from an earlier one.
