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

This document is the canonical recipe for **lifting a component
from a consumer project (e.g., vm-vmanager-app) 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
`mgf-fed standards SCOPE`. 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 the engineering
  standards (`mgf-fed standards <TOPIC>`) 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). vm-vmanager-app + the example app + a real third
  consumer is the strongest signal.
- [ ] **Read `mgf-fed standards SCOPE` §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/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 vm-vmanager-appWidgetLocal # 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. **Note:** the v0.28 federation decision (see [`../inprogress/federation_roadmap.md`](../inprogress/federation_roadmap.md)) reverses this default for **framework-shaped** adapters going forward — they ship as siblings. The remaining `mgf.common.<framework>` adapters are the historical leftovers being progressively extracted. | `mgf.common.db`, `mgf.common.alembic`, `mgf.common.django` (until their own extractions) |
| Sibling package (`mgf-<framework>`) | The adapter is framework-shaped (its own runtime, middleware, lifespans), has its own deep state, OR is large enough to dilute `mgf.common`'s scope. **Default since v0.28** for new framework adapters. | `mgf-apiprobe` (probe scheduler), `mgf-fastapi` (extracted v0.28 from `mgf.common.fastapi`), `mgf-http` (extracted v0.29 from `mgf.common.http`) |

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

- They are **stateless**. `mgf.common.db.setup_db` is one lifespan
  helper. None of them carry state that warrants its own package.
  (The v0.28 extraction of `mgf-fastapi` and the v0.29 extraction
  of `mgf-http` are driven by the framework-shape rule above and
  the install-cost-and-cohesion argument respectively, not by
  adapter state.)
- 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.<adapter>.X`, never reach into
  `mgf.common.<adapter>._foo`. Internal modules stay
  underscore-prefixed. (Same rule applies in extracted siblings:
  `mgf.fastapi.X`, never `mgf.fastapi._foo`.)

### 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
  # Example shape — historical (pre-v0.28). The same lazy-import
  # discipline applies inside extracted siblings, just with a
  # different install hint:
  try:
      from sqlalchemy.ext.asyncio import AsyncEngine
  except ImportError as e:
      raise ImportError(
          "mgf.common.db requires the [db] extra. "
          "Install with: pip install 'mgf-common[db]'"
      ) 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/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/recipes/multi_tenant_settings.md`](../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 vm-vmanager-app's
   then-private VMCredential, which became a private adapter
   internal to vm-vmanager-app (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:*
- [`SCOPE.md`](https://codeberg.org/magogi-admin/mgf-standard/src/branch/main/docs/standards/SCOPE.md) — what's in / out / adjacent for `mgf.common`.
- [`API_DESIGN.md`](https://codeberg.org/magogi-admin/mgf-standard/src/branch/main/docs/standards/API_DESIGN.md) — public-API contract rules (AP-01..AP-15).
- [`COMMON_COMPONENTS.md`](https://codeberg.org/magogi-admin/mgf-standard/src/branch/main/docs/standards/COMMON_COMPONENTS.md) — the canonical catalogue of supported components (replaces the historical `COMMON.md` reference).
- [`CONTRIBUTING.md`](../../CONTRIBUTING.md) — dev setup + four green gates + PR process.
