# Promotion Gate

Vibe-coded work has **no stages at all.** It goes from "it works on my laptop" to production because
nothing stopped it. The classical discipline here is the stage gate — work advances through defined
stages and does not advance until it meets the gate's criteria — and it is exactly right and
completely unapplied.

The lesson's three phases, and the only question each one asks:

| Phase | The only question | You leave when… |
|---|---|---|
| **Explore** | Does it work *at all*? | You could show it to someone. |
| **Validate** | Is it *right*? | The paths a real person walks all behave. |
| **Harden** | What happens when it *breaks*? | The care matches who gets hurt. |

**A trigger is a fact you can check, not a feeling you wait for.** "It works end to end" is a fact.
"I feel good about it" never arrives. So every criterion below is *checked*, and the gate prints
PASS or BLOCK — never "looks good".

## Step 0 — What is the feature? Define it as a diff.

A feature you cannot name as a set of changes cannot be gated, reverted, or toggled. Take it from
git, not from memory:

```bash
git diff --stat origin/main...HEAD
```

```bash
git diff --name-only origin/main...HEAD > .claude/scratch/feature-files.txt
```

That file list **is** the feature for the rest of this skill. If it contains changes from three
unrelated jobs, stop: you cannot promote or revert them independently, and that is itself a BLOCK.

## Gate 1 — Explore → Validate

**The trigger: it runs end to end for one straightforward case.**

Not "the code looks complete". Run it.

```bash
npm run build --if-present
```

```bash
npm test
```

| Criterion | How it is checked | BLOCK when |
|---|---|---|
| It runs at all | the project's own build/start command exits 0 | non-zero exit |
| One real path works end to end | the happy path executes and produces the right output | it hedges, errors, or was never run |
| There is a flag planted at the working state | a commit exists whose message marks it | nothing to go back to |

If it hedges instead of answering, the reader is still in Explore, whatever they thought. Mark the
moment it works — everything after this is reversible only because of this commit:

```bash
git status --short
git add -- <approved-feature-path-1> <approved-feature-path-2>
git commit -m "works end to end, before validation"
```

Stage only paths the user confirms belong to this feature. If the approved path list is unknown,
stop before `git add`; never sweep the worktree into the checkpoint.

**Do not run Gate 3's checks here.** Polishing something you have not proved is the lesson's first
failure: every hour spent making the wrong idea beautiful is an hour you delete.

## Gate 2 — Validate → Harden

**The trigger: the few things a real person actually does all behave, and nothing you know about is
broken.**

### The check that matters most: does a test FAIL WITHOUT THE FEATURE?

This is the one criterion nobody ever checks, and it is the one that catches AI-written tests that
assert on the implementation and pass against anything. **Take the feature away and run the suite. It
must go red.** If it stays green, the tests do not test the feature — they are decoration, and the
green light is not evidence.

Do it in a throwaway worktree so the working tree is never at risk:

```bash
git worktree add ../gate-probe HEAD --detach
```

```bash
cd ../gate-probe && git checkout origin/main -- $(cat ../<repo>/.claude/scratch/feature-files.txt | rg -v '^(test|spec|.*\.test\.|.*\.spec\.)' ) && npm test; echo "EXIT: $?"
```

Revert **only the source files**, never the test files — that is the whole trick. You keep the new
tests and remove the new behaviour. A suite that still passes has just told you the tests are empty.

```bash
git worktree remove ../gate-probe --force
```

| Criterion | How it is checked | BLOCK when |
|---|---|---|
| **A check fails without the feature** | remove the feature's source, keep its tests, re-run | **the suite still passes** |
| Bad, empty and unexpected inputs are enumerated | every input path in the diff has a stated behaviour for empty / wrong-type / hostile | any input reaches logic unchecked |
| Nothing known is broken | full suite green **with** the feature | any failure |

The stopping rule, because Validate has no natural end and will otherwise eat a week: **write down
the three to five things a real person actually does with this.** Check those. Exotic cases wait for
a real complaint.

## Gate 3 — Harden → Ship

**The trigger: the care matches who gets hurt.**

Hardening is not a fixed amount of work — it is **proportional to the blast radius**. A script only
you will ever run does not need retries and a log file; you would be insuring against a sigh.

```bash
rg -n --no-heading -f .claude/scratch/feature-files.txt -e 'await |fetch\(|writeFile|query\(|execute\(|\.post\(|\.delete\('
```

```bash
rg -n --no-heading -f .claude/scratch/feature-files.txt -e 'catch|except|rescue|if err|\.catch\('
```

| Criterion | How it is checked | BLOCK when |
|---|---|---|
| **Every failure point has an error path** | count things that can fail vs. places that catch | a failure point with no catch, and the feature is not a throwaway |
| **It fails with a message a human can read** | no raw stack trace reaches a user | an unhandled error surfaces raw |
| **It leaves a record when it fails** | a log/record on the error path | you would have no way of finding out it broke |
| **It is revertible as one unit** | the feature is one branch / one merge commit, and `git revert` of it is clean | it is tangled with unrelated work |
| **It is toggleable** | a flag or env switch gates the new path, so it can be turned off without a deploy | high blast radius and no off-switch |

Size it honestly first — this is the question that decides how much of the above applies:

> *"If this fails in front of a real person, who is that person and what do they lose?"*

*"It could cause issues"* is not an answer. *"Someone loses the form they just filled in"* is, and it
tells you exactly what to protect. If the honest answer is "nobody but me, and I re-run it", most of
Gate 3 does not apply — **say so in the report and pass it**. Hardening a throwaway is a failure too.

## Step 4 — Emit the verdict

The gate writes one report file per run — `promotion-gate.md`, in the repo root. It carries a
PASS/BLOCK per stage, the evidence behind each, and the one trigger that would promote it:

```markdown
# Promotion gate — <feature> — <date>
**Stage: VALIDATE. Verdict: BLOCK.**
**The trigger that promotes it:** a check that fails when the feature is removed. There isn't one.

## Gate 1 · Explore → Validate — PASS
- Runs end to end: `npm test` exit 0, build exit 0.
- Flag planted: <commit>.

## Gate 2 · Validate → Harden — BLOCK
- **Fails-without-the-feature: NO.** Reverted <n> source files, kept the tests, suite still passed
  (exit 0). The tests assert on the implementation and would pass against anything.
- Inputs: <n> entry points; <n> have no behaviour stated for empty/wrong input — <file>:<line>.

## Gate 3 · Harden → Ship — NOT REACHED
Blocked upstream. Do not start hardening: you would be polishing something you have not proved.

## Who gets hurt
<the named person and what they lose — or "nobody but me", which lowers the bar and is allowed>
```

## Step 5 — Wire it as a pre-merge check, so it runs unattended

A gate that a human has to remember to run is a gate that is skipped exactly when someone is in a
hurry — which is precisely when it was needed.

Write the checks into `.claude/gates/promotion-gate.mjs` (it re-runs Gates 1–3 against
`origin/main...HEAD` and exits non-zero on any BLOCK), then run it in CI on every pull request:

```bash
gh pr checks
```

`.github/workflows/promotion-gate.yml`:

```yaml
name: promotion-gate
on: pull_request
jobs:
  gate:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with: { fetch-depth: 0 }
      - run: npm ci
      - run: node .claude/gates/promotion-gate.mjs --base origin/${{ github.base_ref }}
```

Locally, the same script can run as a Claude Code hook so the agent is stopped before it merges.
**Use exit code 2 — not 1.** Claude Code treats **exit 1 as a non-blocking error and proceeds with
the action**; only exit 2 blocks and feeds stderr back to the model
([hooks docs](https://code.claude.com/docs/en/hooks), fetched 2026-07-14). A gate that exits 1 is a
gate that watches you merge.

## What this skill will not do

- **It will not judge whether one component is any good.** Damage × quality → keep / harden / rewrite,
  across every component of a prototype, is **`vc-prototype-triage`**. This skill governs how a
  feature *advances*; that one grades what you already have.
- **It will not fix a rotten zone.** Bounded refactor vs hand-refactor vs greenfield is
  **`vc-vibe-debt-triage`**.
- **It will not prove your whole suite can fail.** It proves one thing, narrowly and well: that a
  check exists which fails when *this feature* is removed. Whether the suite as a whole is real —
  mutation-grade — is **`vc-ai-test-validator`**.
- **It will not own the shipping mechanics.** Ramps, merge queues and five-minute reverts belong to
  the release lesson. This skill names the trigger that says *go*; it does not run the deploy.
- **It will not let you harden what you have not validated.** Gate 3 is unreachable while Gate 2 is
  BLOCK, on purpose. Polishing an unproven idea is the most expensive habit in the lesson.
