# Recipe: draining a consumer off a retiring umbrella package

> **Audience:** anyone migrating a consumer (app or sibling) off a
> **retiring umbrella `mgf-*` package** onto the granular siblings /
> neutral contracts that replace it — slice by slice, without breaking
> or silently degrading the consumer.
> **Worked example:** `vm-vmanager-app` draining off **`mgf-vm`** (the
> umbrella — now fully retired and deleted by the vm-vmanager-app team)
> onto `mgf-vm-core` / `mgf-vm-sdk` / `mgf-vm-libvirt` (the neutral SDK +
> provider contracts). The drain is complete: every former consumer pins
> `mgf-vm-core` and no repo depends on the umbrella.
> **What this is:** a how-to playbook, not a spec. Its hard part — the
> **slice classification gate** — is the rule that keeps a drain honest.

## Why this exists (PAPER-02)

An umbrella package gathers provider implementations behind one import.
Retiring it means each consumer must repoint its calls at the granular
siblings — typically a neutral **SDK value type** or **observation
contract** instead of the umbrella's concrete provider method. The work
is done in **slices** (one capability at a time: a facts query, a storage
read, a snapshot lookup).

The trap is **misclassifying a slice**. Each slice is either:

- **app-only** — the neutral surface already carries everything; just
  repoint the client and ship; or
- **cross-repo-blocked** — the neutral SDK/contract is genuinely missing
  a field the consumer needs, so a sibling must be changed *first*.

Get this wrong in either direction and you pay:

| Failure mode | What it costs | vm-vmanager-app evidence |
|---|---|---|
| **False cross-repo** — defer an app-only slice as a multi-repo campaign | burns a cross-repo campaign on work that was local all along | `security_facts` deferred as a 3-repo change; was fully app-only — `libvirt_uri` already in app config, network isolation already on `NetworkProfile.forward_mode` (`None` ⇒ isolated), and `domain_pid` was **never implemented on the real provider** (a dormant no-op — nothing to port). (PR #35) |
| **False app-only** — repoint naively against a too-thin contract | silently **drops data** the consumer was reading | `storage` reads + `vm snap info` looked like a clean client repoint but `PoolInfo` lacked `available_bytes`/`active` and `_SnapshotInfoContract` lacked `state`; a naive repoint would have dropped those fields |

This is **how-to**, not a binding rule. It applies the federation's
layering + API-stability rules (`mgf-fed standards`,
[`../../FEDERATION.md`](../../FEDERATION.md)); the structural cutting of a
monolith into a library + app lives in
[`library-app-separation.md`](library-app-separation.md). This recipe is
the *migration* discipline once those siblings exist.

## The slice classification gate (do this for EVERY slice)

Before you call a slice app-only **or** cross-repo-blocked, enumerate
every provider/umbrella call the slice removes, and for **each** call
answer both questions against the code — not from the method name:

**Q-A — Does the neutral SDK/contract value type carry every field the
consumer actually *reads*?**
Inspect the *value type's fields*, not just whether a same-named method
exists. A method `get_pool_info()` that returns a `PoolInfo` missing
`available_bytes` does **not** carry the data the consumer reads off it.
Trace each attribute the consumer touches to a field on the neutral type.

**Q-B — Does the legacy umbrella implementation actually *populate* that
data in production?**
A method that exists but was **never implemented on the real provider**
(returns `None`, raises, or is a stub) is a **dormant no-op** — there is
nothing to preserve. Confirm the legacy path populated the field on the
*production* provider, not just in a contract or a test double.

### Decide from the two answers

| Q-A: neutral type carries every read field? | Q-B: legacy populates it in prod? | Classification |
|---|---|---|
| **Yes** | (any) | **app-only** — repoint the client, ship the slice |
| No (a field is missing) | **No** — dormant no-op | **app-only** — the field was never real; drop it (don't port a no-op) |
| No (a field is missing) | **Yes** — real production data | **cross-repo-blocked** — the sibling SDK/contract MUST add the field first |

A slice is **cross-repo-blocked only when a field the consumer reads is
both missing from the neutral type AND genuinely populated in
production.** Everything else is app-only. (Equivalently, PAPER-02's
phrasing: a slice may be declared cross-repo only after *both* checks
fail — the neutral type is missing the field **and** the legacy really
produced it.)

### When a slice is cross-repo-blocked

File the field gap as a recommendation in the **sibling's** `FEEDBACK.md`
(the SDK/contract owner adds the field — you do not reach in; see the
sync-prompt guardrails). Land the unblockable part of the slice if it
stands alone; otherwise defer the slice with the specific missing field
named (`PoolInfo.available_bytes`, not "storage needs work"). Pick the
sibling change back up once it ships and resolves the gap.

## Method — per slice

1. **List the removals.** Enumerate every umbrella/provider call the
   slice deletes.
2. **Run the gate** (Q-A + Q-B) for each call; record the classification
   with the field-level evidence, not a hand-wave.
3. **App-only slices:** repoint the client at the neutral surface, delete
   the umbrella import, cover the new path with a behavioural test
   (TS-*), run the gate green (`ruff` · `mypy --strict` · `lint-imports`
   · tests · `gitleaks`).
4. **Cross-repo-blocked slices:** file the field gap to the sibling's
   `FEEDBACK.md`, defer with the missing field named, move to the next
   slice.
5. **Land** via [`codeberg-pr.md`](codeberg-pr.md). Don't merge or bump
   versions unsolicited — that's the supervisor's call.

## Anti-patterns

- **Trusting the method name.** A same-named method is not the same
  surface — verify the *fields* the consumer reads exist on the neutral
  value type (Q-A).
- **Porting a dormant no-op.** A never-implemented umbrella method is not
  a feature; don't manufacture a cross-repo campaign to "preserve" it
  (Q-B).
- **Naive repoint that drops data.** Shipping an "app-only" slice against
  a contract missing a field the consumer read = silent data loss. The
  gate exists to catch exactly this.
- **Reaching into the sibling.** A cross-repo-blocked slice surfaces as a
  `FEEDBACK.md` recommendation to the SDK owner, never as an edit you make
  to the sibling.

## Related

- [`library-app-separation.md`](library-app-separation.md) — cutting a
  monolith into the library + app this drain then migrates onto.
- [`sibling_rename.md`](sibling_rename.md) — the sibling-lifecycle
  counterpart (rename rather than retire).
- [`codeberg-pr.md`](codeberg-pr.md) — land each drained slice as a PR.
- [`cohort-release-wave.md`](cohort-release-wave.md) — shipping the
  sibling release that unblocks a cross-repo-blocked slice.
- [`../../FEDERATION.md`](../../FEDERATION.md) — layering + dependency
  direction the drain must respect.
