# Lifting a component to `mgf-common` — checklist

This document is the canonical recipe for **lifting a component
from a consumer project (e.g., VManager) into `mgf-common`**. It
turns the implicit "we'll know when we see it" criterion into a
mechanical 8-step checklist.

When to lift, when to keep in-consumer, and which sibling
package a lift candidate belongs in are addressed in
[`docs/standards/SCOPE.md`](standards/SCOPE.md). This document
is the *how*, not the *whether*.

---

## When NOT to lift

Don't lift just because something is "useful." Real lift signal:

- **Two consumers want the same thing.** Without a second consumer,
  the abstraction is shaped for one; lifting prematurely bakes
  consumer-1's idiosyncrasies into the library.
- **The standards demand it.** A `MUST` rule in `docs/standards/`
  that consumers MUST satisfy is a lift candidate (the library
  ships the canonical implementation).
- **A federation-wide policy needs an enforcement seam.** Audit
  logging, redaction, lifecycle hooks — these are policy that
  belongs above any single consumer.

If none of those apply: keep it in the consumer, dogfood it, lift
later.

---

## Pre-flight (before opening a PR)

- [ ] **Confirm there's a second consumer.** `examples/06_full_app/`
  counts as a "shadow" second consumer once it's a separate
  package (Phase G3). VManager + the example app + a real third
  consumer is the strongest signal.
- [ ] **Read [`SCOPE.md`](standards/SCOPE.md) §SP-04.** Is the
  candidate in-scope, out-of-scope, or adjacent? An adjacent
  candidate needs explicit demand; an out-of-scope candidate
  needs SCOPE.md to be amended first.
- [ ] **Pick the sibling.** Cross-cutting + no system deps + pure
  Python → `mgf.common`. Framework-specific (Qt, Gtk) → its own
  sibling (e.g., `mgf-qt`). System-package deps (libvirt) →
  consumer-specific.

---

## The 8-step recipe

### 1. Extract a `Protocol` first

The library's public surface for the lifted component is a
`runtime_checkable` Protocol — never a concrete class with the
consumer's idiosyncrasies. Mirror the existing `mgf.common.typing`
shape (CancellationProtocol, ReporterProtocol, LogSinkProtocol,
VaultProtocol).

```python
# mgf/common/typing.py — extend with the new Protocol
@runtime_checkable
class WidgetProtocol(Protocol):
    """Cooperative widget interface."""

    def do_thing(self, arg: str) -> int: ...
    def is_ready(self) -> bool: ...
```

The consumer's existing concrete class implements the Protocol
without changes. Tests can pass duck-typed mocks.

### 2. Lift the concrete impl

A subpackage of `mgf.common` (or a sibling repo for non-`mgf.common`
lifts):

```
src/mgf/common/widgets/
├── __init__.py re-exports
├── _registry.py if pluggable
├── _local.py concrete impl 1
└── _remote.py concrete impl 2
```

Naming convention: underscore-prefixed private modules; public
names re-exported from `__init__.py`.

### 3. If pluggable, ship a registry

Use the [`mgf.common.registry`](../src/mgf/common/registry.py)
generic primitive (Phase G5):

```python
WIDGETS = make_registry(
    "widget",
    factory_signature=Callable[[WidgetConfig], WidgetProtocol],
)

# Backwards-compat: old-style decorator stays:
register_widget = WIDGETS.register
```

Don't bolt the registry on later — the registry IS part of the
public contract. Adding it post-1.0 is a breaking change.

### 4. AP-10 row + tier

Add a row to [`PUBLIC_API.md`](../PUBLIC_API.md) for every public
name. Stability tier is `experimental`.

```markdown
| `WidgetLocal(config: LocalWidgetConfig)` | experimental | 0.X.0 | Local widget impl. ... |
```

The pin test (`tests/unit/test_public_api_doc.py`) requires this —
adding a name to `__all__` without a doc row fails CI.

### 5. CLAUDE.md package-map row + Phase critical facts

[`docs/claude/CLAUDE.md`](CLAUDE.md) is the dense lookup. Two sections to
extend:

- The package map (the ASCII tree + per-line description).
- A new "Phase N critical facts" sub-section if the lift introduced
  any non-obvious gotchas — silently violating one is the most
  common way to break consumers.

### 6. Reference-impl shim in the source repo

The consumer's existing module becomes a thin re-export shim:

```python
# vmanager/widgets.py — was the canonical impl, now a shim
"""Re-export shim for backward compatibility."""
from mgf.common.widgets import WidgetLocal as VManagerWidgetLocal # noqa: F401
```

Document the shim in the consumer's CLAUDE.md so future
contributors don't reinvent the impl locally.

### 7. CHANGELOG entry (lift-side AND consumer-side)

`mgf-common`'s CHANGELOG records the lift as a new public surface.
The consumer's CHANGELOG records the move + the deprecated import
path (with a removal target version).

### 8. FEEDBACK.md retrospective (lift-side)

If the lift closes a 🔴 Blocker / 🟡 High entry from
[`FEEDBACK.md`](../FEEDBACK.md), move the entry to §4 with the
commit SHA + a one-line retrospective: "did the suggested shape
survive contact with implementation?"

The retrospective is the signal that tells maintainers which
consumer voices to weight more heavily in the next round.

---

## Quality gates the lift PR MUST pass

The four standard `mgf-common` gates (per
[`CONTRIBUTING.md`](../CONTRIBUTING.md) §"Four green gates"):

- [ ] `pytest --cov --cov-fail-under=80`
- [ ] `mypy --strict src/`
- [ ] `ruff check src tests`
- [ ] `lint-imports`

PLUS the consumer-side regression check (Phase G4):

- [ ] The reference consumer's test suite still passes against the
      lifted version. The Woodpecker `consumer-pins` step runs this
      automatically; for local validation, install `mgf-common` from
      a path source in the consumer's `pyproject.toml` and re-run
      its test suite.

---

## Common traps

- **Lifting too early.** Without a second consumer, the abstraction
  bakes consumer-1's idiosyncrasies in. Push back; ask for the
  second-consumer signal before merging.
- **Lifting the test infrastructure with the impl.** The lifted
  component's tests stay in `mgf-common`; the consumer's tests
  become integration smoke against the shim.
- **Consumer reaches past the shim.** Once shimmed, the consumer
  MUST import from `mgf.common.widgets`, not the local shim path.
  Add a Phase critical-facts note to enforce.
- **Forgetting the Protocol.** "We'll add the Protocol later" is
  how concrete classes become public contracts that can never
  change. Extract the Protocol IN THE LIFT PR, not after.

---

## Framework adapters: optional extras vs siblings

A **framework adapter** is a thin integration layer between
`mgf.common`'s primitives and a third-party framework (FastAPI,
Django, SQLAlchemy + alembic, requests/httpx, gRPC, etc.). Two
choices when one becomes lift-shaped:

| Place | When to pick | Example |
|---|---|---|
| `mgf.common.<framework>` as an optional extra | The adapter is small, mostly stateless, and reuses `mgf.common`'s primitives directly (logging, identity, exceptions). Pulling it into `mgf.common` keeps the federation leaf coherent. | `mgf.common.fastapi`, `mgf.common.db`, `mgf.common.alembic`, `mgf.common.http` |
| Sibling package (`mgf-<framework>`) | The adapter has its own deep state (e.g., owns a runtime, a CLI, or non-trivial config), or is large enough to dilute `mgf.common`'s scope. | `mgf-apiprobe` (probe scheduler + report shape; owns its own CLI + transports) |

**Why the adapters are extras, not siblings:**

- They are **stateless**. `mgf.common.fastapi.RequestIdMiddleware`
  doesn't own anything across requests; `mgf.common.http.AsyncHttpClient`
  is a thin httpx wrapper; `mgf.common.db.setup_db` is one lifespan
  helper. None of them carry state that warrants its own package.
- They **reuse `mgf.common.*` directly**. Every adapter logs through
  `mgf.common.observability`, raises `mgf.common.exceptions.*`,
  reads `mgf.common.settings`. A sibling would re-import all of that
  through the federation seam — pointless overhead.
- They are **opt-in via extras**. `pip install mgf-common` doesn't
  pull `httpx` / `fastapi` / `sqlalchemy` / `alembic`. A consumer
  uninterested in services pays no install cost.
- The **closed-box rule still holds**: consumers MUST import
  `mgf.common.fastapi.X`, never reach into `mgf.common.fastapi._foo`.
  Internal modules stay underscore-prefixed.

### Checklist for shipping a framework adapter as an extra

In addition to the eight steps above:

- [ ] **Lazy import the framework.** The framework dep ships under
  `[project.optional-dependencies]`; the import in
  `src/mgf/common/<framework>/__init__.py` MAY be top-level (PEP 420)
  but the framework's `import` line MUST raise a clear `ImportError`
  with an actionable message when the extra isn't installed:

  ```python
  try:
      from fastapi import FastAPI
  except ImportError as e:
      raise ImportError(
          "mgf.common.fastapi requires the [fastapi] extra. "
          "Install with: pip install 'mgf-common[fastapi]'"
      ) from e
  ```

- [ ] **Pyproject extras are nested.** The convention is
  `[<framework>]` for runtime + `[<framework>-test]` for the testing
  add-ons (respx, TestClient, in-memory SQLite, etc.). Test extras
  inherit via `mgf-common[<framework>]`.

- [ ] **Tests live under `tests/unit/<framework>/`.** Mirror the
  src tree. The pytest fixture `mgf-common[<framework>-test]` extra
  is the install command for CI.

- [ ] **Runtime-import gotchas tested.** Add a test that imports
  the subpackage with the extra absent and asserts the friendly
  ImportError fires. Without it, an extras-rename in pyproject
  silently masks the failure mode.

- [ ] **The recipe doc is mandatory.** `docs/public/recipes/<framework>.md`
  ships in the same PR. Without a recipe, consumers don't discover
  the adapter; the lift is invisible.

### When in doubt — sibling

If the adapter starts owning state (a CLI, a runtime loop, a config
schema with its own deprecation cycle), spin it out into a sibling
before it grows beyond what an extra justifies. The adapters
are deliberately minimal; once one of them grows past ~500 lines or
needs its own CLI, it's a sibling candidate.

---

## Deferred lifts — the recipe-first list

Some patterns are *known* to be lift-shaped but the second-consumer
signal hasn't materialized. The recipe ships at the planned phase
boundary; the primitive lifts when the second consumer arrives. This
list tracks the deferred ones so future maintainers don't reinvent
the analysis.

### Per-tenant settings (deferred at ; recipe instead)

- **Recipe:** [`docs/public/recipes/multi_tenant_settings.md`](public/recipes/multi_tenant_settings.md)
  ().
- **Why deferred:** Only PlasmaMapper is multi-tenant at cut.
  Lifting now would bake PlasmaMapper's idiosyncrasies (Postgres-
  RLS-shaped, sync-Protocol store) into the library. inthewords
  *might* go multi-tenant in a future phase but hasn't signaled.
- **Promotion triggers:** *any one of:*
  1. A second multi-tenant consumer adopts mgf-common AND ships a
     local copy of the recipe's loader.
  2. The recipe's loader converges across two consumers (same
     Protocol shape, same merge semantics, same validation story).
  3. A consumer files a feature request the recipe can't satisfy
     (async store with invalidation; multi-store composition).
- **Lift target:** `mgf.common.tenant` package; ``MgfTenantSettings``
  (subclass of ``BaseAppSettings``); ``load_for_tenant``;
  ``TenantConfigStore`` Protocol moves to ``mgf.common.typing``.
- **Lift cost:** ~1 day. Schema + loader are ~50 LOC each;
  Protocol re-homing is mechanical; existing recipe becomes
  a "now lifted to mgf.common.tenant" pointer.

When you write a deferred-lift recipe, add a row here so the
intent is discoverable. Remove the row when the primitive lands.

---

## Worked example — the vault lift (Phase 5)

The cleanest worked example in this repo's history.

1. **Protocol extracted first.** `VaultProtocol` landed in
   `mgf.common.typing` in — BEFORE the concrete
   backends in Phase 5.
2. **Concrete impls under `mgf.common.vault/`.** Three built-ins
   (env / keyring / file) as separate underscore-prefixed modules.
3. **Registry shipped alongside.** `register_vault_backend` was
   in the same Phase 5 commit — not a follow-up.
4. **PUBLIC_API.md rows added.** Six new public names; AP-10 pin
   tests grew from 90 to 108 names.
5. **CLAUDE.md updated.** New "critical facts" section
   covers the closed-box leak fix + the lazy-import pattern for
   the optional `[vault]` extra.
6. **Reference-impl shim:** N/A — the lift was from VManager's
   then-private VMCredential, which became a private adapter
   internal to VManager (not a backward-compat concern).
7. **CHANGELOG entry.** release notes called out the lift
   as the closure of FEEDBACK §3.2.
8. **FEEDBACK retrospective.** §3.2 closed in commit `96a70c4`.

The follow-up (typed configs replacing
`dict[str, Any]`) is the second swing at the same lift —
illustrates that lifts can iterate.

---

*See also:*
- [`standards/SCOPE.md`](standards/SCOPE.md) — what's in / out / adjacent for `mgf.common`.
- [`standards/API_DESIGN.md`](standards/API_DESIGN.md) — public-API contract rules (AP-01..AP-15).
- [`COMMON.md`](../COMMON.md) §12 — the deferred-lift catalogue.
- [`CONTRIBUTING.md`](../CONTRIBUTING.md) — dev setup + four green gates + PR process.
