# Per-recipe deploy budget (Phase 2b)

**Question:** does a recipe's full CI test sequence redeploy more than necessary?
**Answer:** No. The budget is already minimal — and in fact tighter than the nominal
`1 base + 1 upgrade + N_deps` — because the upgrade tier shares the base deployment.

## The budget

For one cold `!testme`/`run_recipe_ci.py` run of a recipe:

```
deploys == 1 (base) + N_cold_deps
```

- **1 base deploy**, shared by **install → upgrade → backup → restore → custom/functional**.
  All five tiers run against this single deployment. (`run_recipe_ci.py:819`,
  `lifecycle.deploy_app` → `_record_deploy`.)
- **+ 1 per COLD declared dependency** (e.g. an SSO provider deployed in-run), each deployed
  **once** and reused (`deps.py:81-120`, one `deploy_app` per dep). A **live-warm** dep
  (e.g. a resident keycloak that only gets a per-run realm, not a fresh deploy) contributes **0**.
- The **upgrade tier adds NO deploy.** When the upgrade tier runs, the *base* deploy is done at
  the **previous published version** (`run_recipe_ci.py:746-754`: `base = prev or target`), and the
  upgrade is an **in-place `abra app deploy --chaos`** redeploy of the PR-head code onto that same
  running app (`generic.perform_upgrade` → `lifecycle.chaos_redeploy`). `chaos_redeploy` does **not**
  call `deploy_app`, so it is **not counted** — and it is the *real* upgrade the PR's changes are
  exercised by (HC1), verified by `assert_upgraded` on the chaos-version label.
- **backup and restore add NO deploy.** They operate on the same running app
  (`perform_backup`/`perform_restore` → `backup_app`/`restore_app`); neither calls `deploy_app`.

### Reconciliation with the plan's nominal budget
Plan B1 states the nominal minimum as `1 (base) + 1 (upgrade tier) + N_deps`, assuming the upgrade
tier needs its own prior-version deploy. The cc-ci design is **stricter**: the base deploy *is* the
prior-version deploy (when upgrade runs), and the upgrade is performed **in place**. So the
prior-version deploy and the base deploy are the **same** deploy — there is no separate upgrade
deploy. Net actual budget: `1 + N_cold_deps`. This is the deploy-sharing the operator expected.

## Enforcement (not just claimed)

The harness counts every `deploy_app()` (the only caller of `_record_deploy`, `lifecycle.py:107-211`)
into a per-run countfile and **hard-fails** on a mismatch:

- `expected_deploy_count = 1 + deps_deployed_count` — `run_recipe_ci.py:984`
  (`deps_deployed_count` excludes warm deps, `:982-983`).
- RUN SUMMARY prints `deploy-count = N (expect M)` — `run_recipe_ci.py:986`.
- `if deploy_count != expected_deploy_count: … overall = 1` (DG4.1 violation, non-zero exit) —
  `run_recipe_ci.py:1005-1010`.

So every green run is a *proof* that the recipe stayed within budget: a redundant redeploy would
push `deploy_count` above `expected` and turn the run red. No recipe can silently exceed the budget.

### Verify from a cold clone
```
RECIPE=ghost        STAGES=install,upgrade,backup,restore,custom  cc-ci-run runner/run_recipe_ci.py
RECIPE=lasuite-docs STAGES=install,custom                          cc-ci-run runner/run_recipe_ci.py
```
Expected RUN SUMMARY lines:
- no-dep recipe (ghost): `deploy-count = 1 (expect 1)`, all tiers `pass`.
- cold-dep recipe (lasuite-docs + cold keycloak): `deploy-count = 2 (expect 2)` —
  `deps deployed: ['keycloak']` — all tiers `pass`, `DEPS teardown` clean.
- warm-dep recipe (lasuite-meet, live-warm keycloak): `deploy-count = 1 (expect 1)`,
  `deps deployed: ['keycloak']`.

Observed across all Phase 2 recipe runs: every recipe ran at `deploy-count = 1` (no/warm deps)
or `deploy-count = 2 (expect 2)` (one cold dep). No run exceeded `1 + N_cold_deps`.

## No test weakened to share the deploy
Sharing one deployment does **not** skip or soften any check:
- install, upgrade, backup, restore, custom each still run their **real generic + overlay
  assertions** against the shared app (`run_lifecycle_tier`, `ALL_STAGES`).
- the upgrade is a **real** prev→PR-head crossover (`assert_upgraded` on the chaos-version label),
  not a no-op.
- backup→restore is **real data-integrity** (P4: seed → backup → mutate → restore → assert the
  seeded data survived), not health-only.
- per-run isolation/teardown is unchanged (`DEPS teardown`, app undeploy, volume/secret cleanup).

Only the **deploy count** is constrained; coverage is untouched.

## Out of scope of the budget (intentionally)
- **WC5 canonical promote** (`promote_canonical`, `run_recipe_ci.py:682-707`) deploys a separate
  `warm-<recipe>` app to (re)seed the warm-cache canonical. It runs **only** on a green cold run on
  LATEST, **after** the deploy-count assertion, and explicitly **pops** `CCCI_DEPLOY_COUNT_FILE`
  (`:697`) so it does not perturb the per-run test budget. It is warm-cache maintenance, not a test
  deploy.
- **`--quick` fast lane** (`run_quick`) reuses an existing data-warm canonical and is a separate
  optimization path; the cold full run above is the budget of record.

## Conclusion
The per-recipe deploy budget is **already minimal** and **enforced**: `1 + N_cold_deps`, with the
upgrade tier sharing the base deploy in place. No redundant deploy was found; none was removed
because none existed. (Phase 2b, 2026-05-31.)
