# Multi-tenancy in `mgf-common` — what's supported, what isn't, and why

> **Audience:** consumers evaluating `mgf-common` for architectures
> where more than one logical "app" runs in a single Python process
> — plug-in frameworks, multi-tenant services, monorepo test
> orchestrators.

This document is a position paper. It commits `mgf-common` to a
specific shape so consumers can build against it confidently
instead of discovering the boundaries through pain.

The shape: **`mgf-common` supports nested `AppContext` scopes for
library-in-library architectures (the 5% case), and explicitly
forbids per-request identity isolation in single-process servers
(the 0.5% case that looks tempting but isn't what `mgf-common`
is for).**

The rest of the document explains both halves.

## TL;DR

- ✅ **Supported:** plug-in framework hosting child packages with
  their own identity; monorepo test runs across multiple consumer
  projects; long-running services embedding sub-components that
  bring their own observability identity.
- ❌ **Not supported:** per-request identity isolation in a
  single-process HTTP server. Use the `request_id` contextvar
  (`mgf.fastapi.current_request_id()`, sibling `mgf-fastapi`) for per-request
  correlation; do NOT push a fresh `AppContext` per request.
- ⚙️ **Mechanism:** `AppContext` is a context manager that pushes
  itself onto a PEP 567 contextvar on `__enter__` and pops on
  `__exit__`. `current_context()` checks the contextvar first,
  then falls back to the process-global set by `bootstrap()`.

## The shape: hybrid global + scoped contextvar

`mgf.common.bootstrap()` sets a process-wide global `AppContext` —
that's the 95% case. One app, one process, one identity. Every
observability call (`get_logger`, `operation_span`,
`report_now`, `setup_logging`) reads identity through
`mgf.common.app_name()` / `app_version()`, which resolves the
active context: contextvar first, then process-global.

`mgf.common.AppContext("name", "version")`, used as a context
manager, pushes a *scoped* sub-context onto the contextvar.
While the `with` block is active, observability resolves to the
sub-context's identity. On `__exit__`, the contextvar pops
back; observability resolves to the parent (or global) again.

```python
import mgf.common

with mgf.common.bootstrap(app_name="myapp") as outer:
    do_main_work()  # logs land at app=myapp

    with mgf.common.AppContext("plugin", "1.0"):
        run_plugin_code()  # logs land at app=plugin

    # After the inner with: back to app=myapp
    do_more_work()
```

PEP 567 semantics give correct isolation across asyncio tasks
and threads — each task / thread has its own contextvar value.
The fast path is a single `ContextVar.get()` call (~30 ns), so
observability hot paths don't pay a measurable cost.

## ✅ Supported scenarios

### S1. Plug-in framework hosting child packages

Your app loads plug-ins at runtime, each plug-in is a separate
package with its own identity, and you want logs / spans /
crash reports from each plug-in tagged with that plug-in's
name.

```python
def run_plugin(plugin_name: str, plugin_version: str, fn) -> None:
    with mgf.common.AppContext(plugin_name, plugin_version):
        fn()
```

Logs emitted by `fn()` carry `app=plugin_name`. Crash reports
land in `<state>/plugin_name/crashes/`. OTel spans use
`service.name=plugin_name`. The host's identity is restored
when the scope exits.

**Caveat: scoped contexts do NOT re-wire logging or OTel.** The
host process owns the wiring (handlers, OTel exporters, crash
sinks) — the scoped context only changes the identity those
subsystems read. If a plug-in needs different *handlers*
(e.g., its own log file), the host has to wire that itself,
typically by registering an additional `LogHandlerFactory` /
`CrashSink` at host bootstrap. See
[`05_observability.md`](../reference/observability.md) for the
registration shape.

### S2. Monorepo test runs across multiple consumer projects

Your test harness imports tests from multiple consumer
projects in a single pytest run. Each consumer has its own
identity and `MgfSettings`. You want each test's
observability to land at that consumer's identity, not the
harness's.

```python
@pytest.fixture(params=[("consumer-a", "1.0"), ("consumer-b", "2.1")])
def per_consumer_ctx(request):
    name, version = request.param
    with mgf.common.AppContext(name, version):
        yield
```

Per-task contextvar isolation means parallel pytest workers
(via `pytest-xdist`) don't see each other's identities — each
worker has its own contextvar value.

### S3. Long-running services embedding sub-components

A service that hosts multiple sub-components (e.g., a worker
pool processing jobs from different queues, each "tenant" of
your platform) and wants per-component identity for triage.

```python
async def process_job(job: Job) -> None:
    with mgf.common.AppContext(job.tenant_app, job.tenant_version):
        await _do_work(job)
```

Each job's logs and traces tag with the tenant's identity. The
service's own bootstrap context is restored when the job
finishes.

**Important:** this is **not** the same as per-request
identity isolation in an HTTP server (see ❌ below). It works
when:

- You have a finite, slow-changing set of "tenants" (a few
  dozen, not a few million).
- Each tenant has a real distinct identity that operators
  triage by — `app=` is a meaningful axis in your log
  aggregator.
- The cost of pushing / popping the contextvar per job is
  acceptable (it's tiny — ~30 ns — but per-request HTTP
  hot paths usually want flatter identity).

## ❌ Not supported: per-request identity in a single-process server

Don't do this:

```python
# ANTI-PATTERN — do not push an AppContext per request
@app.middleware("http")
async def per_request_identity(request, call_next):
    with mgf.common.AppContext(
        request.headers.get("X-Tenant", "unknown"),
        "1.0",
    ):
        return await call_next(request)
```

This works mechanically — the contextvar isolates correctly
across asyncio tasks — but it's the wrong tool for the
problem. Reasons:

1. **`app_name` is a coarse identity, not a request label.**
   `app=` is the axis operators triage by ("which app is
   misbehaving?"). Per-request `app=` makes the field useless
   for cross-request triage and floods the cardinality budget
   in your log aggregator.
2. **Crash reports land at `<state>/<app>/crashes/`.** Per-
   request app identity scatters crash artifacts across
   thousands of directories — the ops dashboard for triage
   breaks.
3. **OTel `service.name` is meant to be stable.** Distributed
   tracing systems index spans by `service.name`; per-request
   service names destroy that index.
4. **There's already a primitive for the per-request shape.**
   `request_id` (the contextvar set by
   `mgf.fastapi.RequestIdMiddleware` (sibling `mgf-fastapi`) /
   `mgf.django.MgfRequestIdMiddleware`) is what
   per-request correlation is for. Read it via
   `mgf.fastapi.current_request_id()`. It propagates
   through the same OTel span context. It costs nothing.

The shape per-request servers want:

```python
# CORRECT — one AppContext for the service; per-request
# correlation via request_id contextvar
import mgf.common
from mgf.fastapi import (
    RequestIdMiddleware,
    current_request_id,
)

with mgf.common.bootstrap(app_name="my-service"):
    app = FastAPI()
    app.add_middleware(RequestIdMiddleware)

    @app.get("/users/{id}")
    async def get_user(id: str):
        # request_id propagates through every log + span;
        # tenant info goes in extra={...}, not in app_name.
        log.info("fetching", extra={"tenant_id": id})
```

If you want per-tenant configuration (rate limits, feature
flags, secrets), use the `mgf.common.versioned`
`VersionedFileStore[T]` pattern + a `TenantSettings` loader
keyed by `request.state.tenant_id`. See
[`recipes/multi_tenant_settings.md`](../recipes/multi_tenant_settings.md)
for the canonical shape.

## ⚙️ The contract

The following is part of `mgf-common`'s public API contract.
Consumers may build against it; we will not break it without a
deprecation cycle.

| Promise | Meaning |
|---|---|
| `bootstrap()` sets a process-wide global `AppContext`. | One per process. Re-entry is allowed but emits an audit log; the previous context is shut down LIFO. |
| `AppContext(name, version)` as a context manager pushes a scoped sub-context. | Stacks arbitrarily deep. PEP 567 contextvar semantics — correct under asyncio + threading. |
| `current_context()` resolves contextvar first, then global. | ~30 ns fast path. Returns `None` only when neither is set (emits `UnconfiguredWarning` once per process). |
| Scoped contexts inherit nothing automatically. | If you want the parent's settings, pass `settings=current_context().settings` explicitly. Scoped contexts get fresh `MgfSettings()` defaults otherwise. |
| Scoped contexts do NOT re-wire observability subsystems. | Logging / OTel / crash hooks stay parent-owned. Only identity shifts. |
| `__exit__` always pops the contextvar token. | Even on exception. The pop happens via `try/finally`; nested scopes never leak. |

The contract holds for `mgf-common >= 1.0`; in the pre-1.0
window, the behavior described here is what ships and what
the test suite pins (`tests/unit/test_multitenancy.py`),
but a SHOULD rather than a MUST.

## Limits we'll accept feedback on

The current design forecloses on a few things by intent. If
you have a use case that needs them, file a `FEEDBACK.md`
entry — these aren't decisions we're hostile to revisiting,
they're decisions we made because no real consumer needed the
alternative yet.

- **Per-scope wiring overrides** (e.g., a scoped context that
  attaches its own log handlers and detaches them on exit).
  Today the host owns wiring. A future opt-in
  `AppContext(..., wiring=...)` mode could change this.
- **Identity propagation across process boundaries** (e.g.,
  spawning a subprocess that inherits the active scoped
  identity). Today subprocesses get their own bootstrap. The
  request_id contextvar is propagated through OTel context
  injection if your transport supports it.
- **Cross-task identity inheritance for explicitly-spawned
  tasks** (e.g., `asyncio.create_task` from inside a scoped
  block). Today, PEP 567 says new tasks inherit the
  context-at-creation-time. That's the correct shape; if you
  need *not* to inherit, use `contextvars.copy_context().run(...)`
  with a fresh context.

## See also

- [`01_lifecycle.md`](../reference/lifecycle.md) — `bootstrap()`,
  `AppContext`, `current_context()` mechanics.
- [`recipes/multi_tenant_settings.md`](../recipes/multi_tenant_settings.md)
  — the per-tenant settings recipe (the right shape for
  per-request config).
- [`recipes/versioned.md`](../recipes/versioned.md) —
  `VersionedFileStore[T]` for tenant-config storage.
- [`05_observability.md`](../reference/observability.md) — how identity
  flows through logging / OTel / crash reporter.
- `mgf-common`'s test suite at
  [`tests/unit/test_multitenancy.py`](../../tests/unit/test_multitenancy.py)
  — pins the contract above.
