# v0.19.0 — Cutover guide

> **Audience:** consumer agents (and humans relaying for them)
> upgrading to `mgf-common` 0.19.0.
> **What this is:** the canonical migration document for this
> release. Per [`RELEASING.md`](../standards/RELEASING.md) **REL-09**
> + the cutover-guide pattern, individual per-entry notification
> paragraphs are pointers into this guide.

This release is the **composition + framework-native** batch:
v0.18.0 introduced HTTP-01 (`HttpError`) and the strategic-review
priority 1 ("get a second consumer") was achieved through three
real adoptions, but two follow-ups surfaced —

  - The HTTP-01 keystone was missing: the FastAPI middleware
    didn't know to read the new `http_status` class attribute, so
    HTTP-01 leaves and `ExceptionTranslationMiddleware` didn't
    compose.
  - The Django adoption walk (inthewords) hit a structural
    bootstrap blocker: `bootstrap()` couldn't share a `.env` with
    Django's settings, AND it unconditionally clobbered the
    framework's logging dictConfig.

Plus two PostgreSQL/pytest nice-to-haves from PlasmaMapper that
are cheap to ship in the same window.

---

## At-a-glance

| Entry | Severity | What landed | Code or docs? |
|---|---|---|---|
| [PAPER-24](#paper-24--exceptiontranslationmiddleware-reads-http_status) | 🟡 | Middleware `_lookup_status` now falls back to `HttpError.http_status` when `status_map` misses | code |
| [BOOTSTRAP-01](#bootstrap-01--django-adoption-blocker) | 🟡 | (1) `dotenv_filtering="match_prefix"` default — `.env` honours `env_prefix`; (2) `bootstrap(setup_logging=False)` flag for framework-owned logging | code |
| [PAPER-22](#paper-22--alembic-include_object-pass-through) | 🟢 | `configure_env(include_object=, include_schemas=)` pass-through to `alembic.context.configure` | code |
| [PAPER-23](#paper-23--pytest_plugin-editable-install-caveat-documented) | 🟢 | Editable-install caveat added to plugin docstring + `recipes/testing.md` — opt in via `pytest_plugins = ["mgf.common.pytest_plugin"]` | docs |

**Pin bump:** `mgf-common = ">=0.19,<0.20"` per the 0.x window
discipline (one minor at a time; AP-03).

**Public-Python-surface change:** `bootstrap()` grows a
keyword-only `setup_logging: bool = True` parameter (additive —
default preserves v0.18 behaviour). `configure_env()` grows two
keyword-only parameters `include_object` + `include_schemas`
(additive — defaults preserve v0.18 behaviour).

**Behaviour change:** `BaseAppSettings` subclasses with
`extra="forbid"` (the default via `settings_config()`) and a
non-empty `env_prefix` will now **silently ignore** unprefixed
keys in their `.env` file instead of raising `ValidationError`.
This is the BOOTSTRAP-01 fix. Consumers with `extra="allow"` who
were relying on the legacy permissive dotenv behaviour to surface
unprefixed keys onto their model — pass `dotenv_filtering=None`
in your `model_config` override to restore the old shape.

**No deprecation cycles introduced.**

---

## PAPER-24 — `ExceptionTranslationMiddleware` reads `http_status`

The HTTP-01 keystone. v0.18.0 introduced `HttpError` with
`http_status: int` as a class attribute, but
`mgf.common.fastapi.ExceptionTranslationMiddleware._lookup_status`
only walked the `status_map` MRO — it never read `http_status`.
A consumer who rebased their domain exception on `HttpNotFound`
still had to register every leaf in `status_map` for the
middleware to find the 404. Information was redundantly written
into the map.

**What changed:** `_lookup_status` now resolves in this order:

1. **`status_map` entry for any non-`AppError` ancestor** — the
   explicit override seam. `AppError` itself is deferred because
   it acts as a catch-all and would mask the class-declared
   status below.
2. **Class-declared `HttpError.http_status`** — HTTP-01 leaves
   self-describe.
3. **`status_map[AppError]` catch-all** (default 500).

**Migration:** consumers who already rooted their domain
exceptions in `HttpError` subclasses can now drop the explicit
`status_map` entries for them.

```python
# Before (v0.18.0)
from mgf.common.exceptions import HttpNotFound
from mgf.common.fastapi import DEFAULT_STATUS_MAP

class TenantNotFoundError(HttpNotFound):  # carries http_status=404
    pass

# Had to register here even though HttpNotFound already says 404:
app.add_middleware(
    ExceptionTranslationMiddleware,
    status_map={
        **DEFAULT_STATUS_MAP,
        TenantNotFoundError: 404,   # ← redundant, but required
    },
)

# After (v0.19.0)
app.add_middleware(ExceptionTranslationMiddleware)
# That's it. Middleware reads HttpNotFound.http_status = 404.
```

`status_map` still wins when present — useful when a project
wants to map a specific subclass to a different status than its
parent's `http_status` declares (e.g., return 410 Gone for a
soft-deleted resource that's-a-404 architecturally).

**Backward compatibility:** every existing `status_map` keeps
working as-is. The fallback only kicks in for types whose MRO
has no non-`AppError` `status_map` hit.

---

## BOOTSTRAP-01 — Django adoption blocker

inthewords (Phase 3) hit two concrete blockers when adopting
`mgf.common.bootstrap()` as the canonical Django wiring entry
point. Both are now fixed.

### Part 1 — `.env` files honour `env_prefix`

**Pre-fix behaviour:** `MgfSettings(env_prefix="MGF_",
extra="forbid")` would raise `ValidationError` on construction
when the project's `.env` carried unprefixed keys
(`SECRET_KEY=...`, `DB_NAME=...`, `ALLOWED_HOSTS=...`). The prefix
governed environment-variable lookups but NOT `.env`-file
scanning — pydantic-settings's default dotenv source read every
key and tried to map them to fields.

**Post-fix:** `mgf.common.config.settings_config()` now defaults
`dotenv_filtering="match_prefix"`. The dotenv source only surfaces
keys matching the configured prefix; everything else is silently
ignored.

```python
# Project's .env (Django-style):
SECRET_KEY=dev-test-secret
ALLOWED_HOSTS=localhost,127.0.0.1
DB_NAME=inthewords
DB_USER=django_user
CELERY_BROKER_URL=redis://redis:6379/0
MGF_OTEL__ENABLED=true       # mgf-common's slice

# v0.18.0: ValidationError with 5 "Extra inputs are not permitted" errors.
# v0.19.0: MgfSettings reads MGF_OTEL__ENABLED, ignores the rest.
```

This is also the ergonomic the maintainer-side rule **CF-09**
hints at — env_prefix as the contract; unprefixed keys belong to
other tools.

**Behaviour change for `extra="allow"`:** if your subclass set
`extra="allow"` and was relying on the legacy permissive dotenv
to surface unprefixed keys onto the model, restore the old shape:

```python
class MySettings(BaseAppSettings):
    model_config = settings_config(
        env_prefix="MYAPP_",
        extra="allow",
        dotenv_filtering=None,  # ← restore legacy dotenv behaviour
    )
```

Every other `extra` mode is unaffected — `forbid` was raising
before, `ignore` was already silent.

### Part 2 — `bootstrap(setup_logging=False)`

**Pre-fix behaviour:** `mgf.common.bootstrap()` unconditionally
called `mgf.common.observability.setup_logging`, installing a
`StreamHandler` + `RotatingFileHandler` on the root logger. For
Django consumers whose `LOGGING` dictConfig had already
configured the project's logging stack at settings-module-load
time, every log line was emitted twice — once through the
project's configured handler, once through mgf's installed
handler with the deployment-coupled-incompatible JSON shape.

**Post-fix:** `bootstrap()` accepts a keyword-only
`setup_logging: bool = True`. When `False`, the handler install
+ per-component `level_overrides` are skipped. Identity, OTel,
excepthooks, AppContext registration, and the bootstrap-log
buffer replay still happen.

```python
# Django: apps/core/apps.py
from django.apps import AppConfig
from mgf.common import bootstrap, MgfSettings

class CoreConfig(AppConfig):
    name = "apps.core"

    def ready(self):
        # Django's LOGGING dictConfig has already run by now.
        # We want mgf-common's identity + OTel + excepthooks but
        # we keep our own logging stack.
        self._mgf_ctx = bootstrap(
            app_name="inthewords",
            app_version=__version__,
            settings=MgfSettings.production(),
            setup_logging=False,   # ← Django owns the LOGGING stack
        )
```

**Default `True` preserves v0.18 behaviour** for every consumer
that didn't hit this. CLI / desktop / non-framework FastAPI
consumers don't change anything.

**Generalises beyond Django.** Any framework that owns its
logging stack — FastAPI via uvicorn's logging, Flask via
`app.logger`, Pyramid — uses the same `setup_logging=False` opt-out.

### Combined Django adoption

```python
# config/settings/prod.py — your existing dictConfig stays as-is.
LOGGING = {
    "version": 1,
    "disable_existing_loggers": False,
    "formatters": {
        "json": {"()": "apps.core.logging.JsonFormatter"},  # deployment-coupled shape
    },
    "handlers": {
        "console": {"class": "logging.StreamHandler", "formatter": "json"},
    },
    "loggers": {"": {"handlers": ["console"], "level": "INFO"}},
}

# apps/core/apps.py
class CoreConfig(AppConfig):
    name = "apps.core"
    def ready(self):
        from mgf.common import bootstrap
        from mgf.common.settings import MgfSettings
        # MgfSettings now skips unprefixed .env keys (BOOTSTRAP-01 part 1).
        # bootstrap() leaves the LOGGING stack alone (BOOTSTRAP-01 part 2).
        bootstrap(
            app_name="inthewords",
            app_version=settings.APP_VERSION,
            settings=MgfSettings.production(),
            setup_logging=False,
        )
```

inthewords Phase 3 is unblocked. The same pattern applies to
any Django app that follows the standard "LOGGING dictConfig in
settings + AppConfig.ready() side-effects" shape.

---

## PAPER-22 — alembic `include_object` pass-through

`mgf.common.alembic.configure_env` accepts two new keyword-only
parameters that pass straight through to
`alembic.context.configure(...)`:

| Parameter | Type | Default | Purpose |
|---|---|---|---|
| `include_object` | `Callable[..., bool] \| None` | `None` | Filter callable: `(obj, name, type_, reflected, compare_to) -> bool`. Returning `False` excludes from autogenerate. |
| `include_schemas` | `bool` | `False` | Let autogenerate see objects in non-default schemas. |

**Canonical use case** — Postgres extensions (PostGIS,
TimescaleDB, pgvector) install their own internal tables that
consumers don't own and don't want autogenerate to revision:

```python
# alembic/env.py
from mgf.common.alembic import configure_env
from myproject.db import Base

EXTENSION_TABLES = {"spatial_ref_sys", "geography_columns", "geometry_columns"}

def _exclude_extensions(obj, name, type_, reflected, compare_to):
    return not (type_ == "table" and name in EXTENSION_TABLES)

configure_env(
    database_url=settings.DATABASE_URL,
    target_metadata=Base.metadata,
    include_object=_exclude_extensions,
)
```

**Defaults preserve v0.18 behaviour.** Consumers who don't use
extensions (or who already disabled autogenerate via project
ADR — PlasmaMapper's stance) need no change.

---

## PAPER-23 — `pytest_plugin` editable-install caveat documented

When `mgf-common` is consumed as an editable install via
`[tool.uv.sources]` (or any tool that wires the source tree into
a sibling project without writing the dist-info
`entry_points.txt`), the `pytest11` registration is silently
skipped — pytest reads entry points from installed dist-info,
and `uv sync` against an editable source target does not write
that file for sibling projects. The fixtures still exist; they
just aren't auto-loaded.

**No code change.** The `pytest11` entry point keeps working for
PyPI-installed consumers (the contract holds where it can hold).

**Documented opt-in for editable installs:**

```python
# tests/conftest.py
pytest_plugins = ["mgf.common.pytest_plugin"]
```

Pytest discovers this at collection start and loads the plugin
before fixtures resolve. Survives `uv sync` re-runs and is more
discoverable than the entry-point form (a reader of conftest
sees the dependency at a glance).

The plugin docstring, plus
[`docs/public/recipes/testing.md`](../public/recipes/testing.md),
now spells this out.

---

## Per-consumer migration notes

### VManager (Qt desktop)

**No changes required.** Your project doesn't use FastAPI, doesn't
use Django, doesn't use alembic, and isn't sibling-source against
mgf-common. PAPER-22/23/24/BOOTSTRAP-01 don't touch your surface.

Pin bump: `mgf-common = ">=0.19,<0.20"`. Run your test suite to
confirm green. Closes-out for this release.

### inthewords (Django web)

**You're the BOOTSTRAP-01 unblock.** Phase 3 of your adoption
walk surfaced both blockers in this release. Migration steps:

1. Bump pin to `mgf-common = ">=0.19,<0.20"`.
2. Confirm your `.env` no longer raises `ValidationError` when
   constructing `MgfSettings()` — the unprefixed Django keys are
   now silently ignored.
3. In `apps/core/apps.py::CoreConfig.ready()` (or wherever your
   Phase 3 work parked the `bootstrap()` call), add
   `setup_logging=False`. Your `LOGGING` dictConfig + the five
   `TestJsonShapeContract` regression tests stay authoritative
   for log shape. mgf-common won't add handlers.
4. Phase 3 adoption walk can complete; promote to Phase 4
   (instrumentation polish) per your decision log.

If you also want OTel auto-init while keeping logging
framework-native, that's exactly what `setup_logging=False` is
for — every other piece (identity, OTel, excepthooks) is still
wired.

### PlasmaMapper (FastAPI scientific platform)

**Three things land for you:**

1. **PAPER-24** — your `_PLASMA_STATUS_MAP` for
   `TenantNotFoundError` / `CrossTenantAccessError` can now be
   simplified. If you rebase those two on `HttpNotFound`, drop
   them from the map; the middleware reads the inherited
   `http_status` (404). If you keep them mapped explicitly to a
   different status, status_map still wins. Whichever is right
   for your project's semantics.
2. **PAPER-22** — if/when you flip autogenerate back on after
   the migration-safety ADR review, `include_object=` is now
   available. The pre-swap `lambda obj, name, ...: not (type_ ==
   "table" and name in {"spatial_ref_sys"})` callable plugs in
   directly.
3. **PAPER-23** — your `pytest_plugins = ["mgf.common.pytest_plugin"]`
   workaround in `tests/conftest.py` is now the documented opt-in
   for editable installs. No change required; the docstring +
   testing recipe now match what you did.

Pin bump: `mgf-common = ">=0.19,<0.20"`. Phase B0 trial closes
cleanly; Phase B1 (the longer fastapi adapter trial) inherits
the composition fix.

---

## Engineering note

PAPER-24 was a regression introduced in v0.18.0 (HTTP-01 added
`http_status` but didn't wire `ExceptionTranslationMiddleware` to
read it). v0.19.0 closes it in the same-release-after window —
the cutover-guide pattern caught it on PlasmaMapper's first
read-through of v0.18.0, before any production consumer hit the
silent rebase-buys-nothing failure mode. The pattern is paying
for itself.

The `dotenv_filtering` change is the kind of cross-cutting fix
that's easier to ship as a default than as an opt-in: every
`BaseAppSettings` subclass benefits without code changes; every
consumer with a single-tenant `.env` is unaffected; consumers
with `extra="allow"` who were relying on the legacy permissive
behaviour have a one-line restore (`dotenv_filtering=None`).

`bootstrap(setup_logging=False)` and `include_object=` are both
strictly additive with safe defaults. `pytest11` editable-install
behaviour is upstream / external (uv); we document the workaround
and move on.
