# Recipe — Per-tenant settings (without a primitive yet)

> **Audience:** consumers building multi-tenant SaaS where each
> tenant needs configuration overrides loaded from a store (DB,
> vault, configuration service) on top of base settings.
> **Status:** recipe — not a primitive yet. The canonical *pattern* until a second multi-tenant
> consumer signals demand for a library primitive. See
> [§"When this becomes a primitive"](#when-this-becomes-a-primitive).

## At a glance

The pattern uses three primitives mgf-common already ships:

- `BaseAppSettings` — the typed, pydantic-validated settings root.
- `mgf.common.db.tenant_session` — the per-request Postgres RLS
  context (also useful as the seam for "find this tenant").
- `mgf.common.vault.VaultProtocol` — the storage abstraction for
  tenant-specific secrets.

Plus one ~50-line consumer-side helper that composes them. That
helper IS the primitive — once we see it written the same way in
two consumers, we lift it to `mgf.common.tenant`.

```python
# myapp/settings/tenant.py
from typing import Annotated
from pydantic import Field

from mgf.common.config import BaseAppSettings, settings_config

class AppSettings(BaseAppSettings):
    """Process-wide base settings. Read from env / pyproject /
    secrets at startup."""

    model_config = settings_config(env_prefix="MYAPP_")

    # Default fields that apply to every tenant unless overridden:
    api_rate_limit_per_minute: Annotated[int, Field(ge=1)] = 600
    feature_flags: tuple[str, ...] = ()
    upstream_timeout_seconds: Annotated[float, Field(gt=0)] = 30.0

class TenantSettings(AppSettings):
    """Per-tenant view. Subclass, do not override fields — just
    promote the typed model, then use ``model_copy(update=...)``
    in :func:`load_for_tenant` to apply overrides."""

    model_config = settings_config(env_prefix="MYAPP_")
    tenant_id: str
```

That's all the schema. Now the loader:

```python
# myapp/settings/load.py
from collections.abc import Mapping
from typing import Protocol

from myapp.settings.tenant import AppSettings, TenantSettings

class TenantConfigStore(Protocol):
    """Storage abstraction. Backends: Postgres, vault, Redis, JSON file.

    Returns a flat dict of override key → value (pre-validation)
    keyed by tenant id. Missing tenants return an empty mapping —
    NEVER None and NEVER raises — the loader treats "no overrides"
    as a normal case.
    """

    def overrides_for(self, tenant_id: str) -> Mapping[str, object]: ...

def load_for_tenant(
    tenant_id: str,
    base: AppSettings,
    *,
    store: TenantConfigStore,
) -> TenantSettings:
    """Merge per-tenant overrides into a fresh validated settings.

    The merge is shallow: any key the store returns replaces the
    matching base field. Pydantic validates the merged result —
    bad overrides raise :exc:`AppConfigError` (via mgf-common's PEP-678-noted wrapping), with the offending key + tenant id in
    the note so operators can pinpoint which tenant's config drifted.
    """
    overrides = dict(store.overrides_for(tenant_id))
    return TenantSettings(
        tenant_id=tenant_id,
        **{**base.model_dump(), **overrides},
    )
```

And the FastAPI integration:

```python
# myapp/api/dependencies.py
from typing import Annotated
from fastapi import Depends, Request
from sqlalchemy.ext.asyncio import AsyncSession

from mgf.common.db import get_session, tenant_session

from myapp.settings.load import load_for_tenant
from myapp.settings.tenant import TenantSettings

def get_base_settings(request: Request) -> AppSettings:
    return request.app.state.base_settings

def get_tenant_id(request: Request) -> str:
    # Extract from JWT, header, subdomain — whatever your routing layer uses.
    return request.state.tenant_id

def get_tenant_settings(
    tenant_id: Annotated[str, Depends(get_tenant_id)],
    base: Annotated[AppSettings, Depends(get_base_settings)],
    request: Request,
) -> TenantSettings:
    store = request.app.state.tenant_config_store
    return load_for_tenant(tenant_id, base, store=store)

@router.get("/api/v1/things")
async def list_things(
    settings: Annotated[TenantSettings, Depends(get_tenant_settings)],
    session: Annotated[AsyncSession, Depends(get_session)],
    tenant_id: Annotated[str, Depends(get_tenant_id)],
) -> list[dict]:
    async with tenant_session(session, tenant_id):
        # Settings are tenant-specific; queries are tenant-isolated by RLS.
        rate_limit = settings.api_rate_limit_per_minute
        # ... your handler ...
```

That's the whole pattern: one schema, one loader, one Depends.

## The four design decisions

### 1. Subclass, don't fork

`TenantSettings(AppSettings)` is a subclass — every base field is
inherited. The loader uses `model_dump()` from base + applies
overrides on top. Adding a new tunable means adding it to
`AppSettings` once; tenants who don't override get the default;
tenants who do override get validation for free.

### 2. The store is a Protocol

`TenantConfigStore.overrides_for(tenant_id)` is the only seam.
Production runs against a Postgres `tenant_config` table; tests
run against an in-memory `dict`; CI integration tests run against
a mocked-out store. No subclassing required.

```python
# Production backend:
class PostgresTenantStore:
    def __init__(self, pool: AsyncEngine) -> None:
        self._pool = pool

    def overrides_for(self, tenant_id: str) -> dict[str, object]:
        # ... query tenant_config WHERE tenant_id = $1, return as dict ...

# Test backend:
class InMemoryTenantStore:
    def __init__(self, data: dict[str, dict[str, object]]) -> None:
        self._data = data

    def overrides_for(self, tenant_id: str) -> dict[str, object]:
        return self._data.get(tenant_id, {})
```

The Protocol is `runtime_checkable` (pattern from
`mgf.common.typing`); `isinstance(store, TenantConfigStore)` works
in tests.

### 3. Validation is the integrity story

A tenant misconfiguring a value (e.g., `api_rate_limit_per_minute=
"unlimited"` because the store is a flat string-keyed table) MUST
fail loud at load-time, not silently mid-request. The pydantic
validation in `TenantSettings(**merged)` raises the `AppConfigError` shape — caught by `ExceptionTranslationMiddleware`
and surfaced as 503 with the offending key and tenant id in the
note.

```python
# A bad override → loud failure with operator-pointable error:
load_for_tenant("tenant-42", base, store=bad_store)
# AppConfigError: ValidationError: api_rate_limit_per_minute=
# value is not a valid integer
# note: tenant_id='tenant-42'; offending key='api_rate_limit_per_minute'
```

### 4. Caching is the consumer's call

The recipe constructs a fresh `TenantSettings` per request. That's
~10–50µs of pydantic validation; for most services it's well below
the request-handling budget. If your store is slow (e.g.,
cross-region Postgres), wrap the store in an in-process cache:

```python
import functools

class CachedTenantStore:
    def __init__(self, inner: TenantConfigStore) -> None:
        self._inner = inner

    @functools.lru_cache(maxsize=10_000)
    def overrides_for(self, tenant_id: str) -> dict[str, object]:
        return self._inner.overrides_for(tenant_id)

    def invalidate(self, tenant_id: str) -> None:
        self.overrides_for.cache_clear()  # or per-key invalidation
```

The Protocol shape stays the same; the cache decision is yours. In
the library primitive (when it lands), caching will likely be a
configurable composition rather than a built-in.

## Storage backends — a sketch

The pattern works against any store that can answer "give me the
overrides for tenant X." Common shapes:

### Postgres `tenant_config` table

```sql
CREATE TABLE tenant_config (
    tenant_id  uuid    NOT NULL REFERENCES tenants(id),
    key        text    NOT NULL,
    value      jsonb   NOT NULL,
    PRIMARY KEY (tenant_id, key)
);

-- RLS so accidental cross-tenant reads are impossible at the SQL
-- layer (composes with mgf.common.db.tenant_session):
ALTER TABLE tenant_config ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_config_isolation ON tenant_config
    USING (tenant_id = current_setting('app.current_tenant')::uuid);
```

The store reads `SELECT key, value FROM tenant_config` (RLS scopes
to the active tenant) and returns `dict(rows)`.

### Vault (per-tenant secrets)

For per-tenant API keys, smtp passwords, etc., compose with
`mgf.common.vault`:

```python
class VaultTenantStore:
    def __init__(self, vault: VaultProtocol) -> None:
        self._vault = vault

    def overrides_for(self, tenant_id: str) -> dict[str, object]:
        return {
            "smtp_password": self._vault.get(f"tenant/{tenant_id}/smtp_password"),
            "stripe_api_key": self._vault.get(f"tenant/{tenant_id}/stripe_api_key"),
        }
```

The vault interface (`get(key) → str`) is already a public Protocol;
no new abstraction needed.

### Configuration service (HTTP)

For dynamic flags from a config service (LaunchDarkly, ConfigCat,
your in-house service), pair with `mgf.common.http.AsyncHttpClient`:

```python
class ConfigServiceStore:
    def __init__(self, client: AsyncHttpClient) -> None:
        self._client = client

    def overrides_for(self, tenant_id: str) -> dict[str, object]:
        # Note: this is sync-shaped per the Protocol; if your service
        # is HTTP, run the call inside an async wrapper or a thread.
        # In + if a primitive ships, the Protocol may have an
        # async variant.
        ...
```

## When this becomes a primitive

Three signals trigger lifting this to `mgf.common.tenant`:

1. **A second multi-tenant consumer adopts mgf-common.** PlasmaMapper
   is the /reference; we're waiting for the second.
2. **The consumer-side helper converges.** If two consumers ship
   roughly the same `load_for_tenant` shape (same Protocol, same
   merge semantics, same validation story), we have lift signal.
3. **A consumer asks for the primitive explicitly** with a
   feature-request that the recipe doesn't satisfy (e.g., async
   store with cache invalidation; multi-store composition).

Until then, the recipe IS the primitive. Implementing it in a
consumer is a one-page change; lifting later is mechanical (the
schema + loader move into `mgf.common.tenant`, the `Protocol`
moves to `mgf.common.typing`, the consumer's local copy becomes a
re-export shim — see `docs/internal/LIFT_CHECKLIST.md`).

## Anti-patterns

- **Don't reach into `app.state.base_settings` from inside views.**
  Use the `get_tenant_settings` Depends. Views that read raw base
  settings will silently bypass per-tenant overrides — a hard bug
  to detect.
- **Don't cache `TenantSettings` across tenants.** The cache key
  MUST include the tenant id. Caching the loader result without
  partitioning by tenant id is a cross-tenant data-leak primitive
  — and pydantic instances are mutable enough that leaks aren't
  always obvious.
- **Don't hide failed loads.** A `ValidationError` from a
  tenant-config row means that tenant's request must fail (loud,
  via 503) — not silently fall back to base settings. Silent
  fallback hides operator-fixable misconfiguration.
- **Don't put high-cardinality fields in `TenantSettings`.**
  Settings are validated config, not request state. If a value
  changes per-request (current user, request id), it lives in
  `request.state` or a contextvar, not the settings instance.

## See also

- `docs/public/recipes/db.md` §"`tenant_session` — Postgres RLS
  multi-tenancy" — the SQL-layer isolation that pairs with this
  pattern.
- `docs/public/04_settings.md` — `BaseAppSettings` deep dive.
- `docs/public/07_vault.md` — credential storage Protocols.
- `docs/internal/LIFT_CHECKLIST.md` — what triggers a recipe → primitive
  promotion.
