# API Design

> **Conformance:** L1 / L2 / L3 (see `README.md`)
> **Audience:** Human engineers AND AI coding agents; library authors and library consumers.
> **Propagation (DOC-18):** changing a rule here ripples — walk DOCUMENTATION.md §10.4 and update its dependents in the same commit (README *Index of MUSTs*, the `mgf-fed standards` / `agents` CLI, and any parallel copies / pins the matrix names).

> **Scope.** Cross-project API-design standard for any project that
> publishes a public surface — a library on the self-hosted devpi index
> (`magogi/prod` — see
> [`PACKAGE_DISTRIBUTION.md`](PACKAGE_DISTRIBUTION.md) PKG-01), an
> internal shared package, an HTTP/IPC contract, a CLI a downstream
> tool automates against. The rules are written for Python libraries
> (the `mgf-common` shape) but apply to every public boundary:
> what consumers depend on, in what shape, with what stability
> guarantees, evolving how. Addresses the [`FEEDBACK.md`](../../FEEDBACK.md)
> 🔴 Blocker ("no PUBLIC_API.md").

> **Web-shape note (consumer applications).** AP-* rules govern
> projects that publish a public Python API surface — `mgf-*`
> libraries on the federation devpi index
> ([`PACKAGE_DISTRIBUTION.md`](PACKAGE_DISTRIBUTION.md) PKG-01),
> internal shared packages, sibling distributions in this
> federation. A **consumer application** that is deployed-as-a-
> service (Django site, FastAPI service, internal tool deployed via
> Ansible / Docker / Kubernetes) typically has **no published
> registry surface and no downstream importers**. For such
> consumers, AP-10
> (`PUBLIC_API.md`), AP-11 (stability tiers), AP-12 (CHANGELOG
> migration guidance), and AP-15 (back-compat shim warnings) may be
> **declined with rationale** per
> [`principles/non-adoption-is-valid.md`](principles/non-adoption-is-valid.md)
> — deploy notes and operator runbooks serve a different audience
> than a CHANGELOG. AP-01..AP-09, AP-13, AP-14 still apply: the
> consumer's internal modules still benefit from `__all__`
> discipline, docstrings, SemVer for the deploy version, the
> `__future__ annotations` rule, the `py.typed` marker if any module
> ships types for in-house importers, and the closed-box rule for
> internal `_` modules. See [`web/README.md`](web/README.md) for the
> cross-domain narrative and
> [`principles/audit-outcomes.md`](principles/audit-outcomes.md) for
> the audit-flag taxonomy (the worked AP-12 example is in that doc).

---

## Rules box (read this first)

| ID | Rule |
|---|---|
| <a id="AP-01"></a>**AP-01** | **The public API surface MUST be enumerated explicitly via `__all__`** at every package and module boundary that consumers import from. Names not in `__all__` are private and MAY change without notice. |
| <a id="AP-02"></a>**AP-02** | **Every public name MUST have a docstring** stating intent, invariants, and at least one usage example. A docstring of "returns the X" is worse than nothing — delete it or rewrite. |
| <a id="AP-03"></a>**AP-03** | **The package MUST follow Semantic Versioning** (`MAJOR.MINOR.PATCH`). Public-API breaks happen ONLY in MAJOR releases. MINOR adds; PATCH fixes. The 0.x window is the only place public-API breaks land in MINOR (per [SemVer](https://semver.org/) spec for 0.x). |
| <a id="AP-04"></a>**AP-04** | **Deprecation cycle MUST be: announce → still works (≥1 MINOR) → removed.** A deprecated name MUST emit `DeprecationWarning` (or `PendingDeprecationWarning` for soft pre-announcement). Removal is breaking. |
| <a id="AP-05"></a>**AP-05** | **Public function signatures MUST be stable.** New required parameters are breaking. New optional parameters MUST be keyword-only with defaults. Removing a parameter is breaking. Renaming is breaking — use an alias + deprecation. |
| <a id="AP-06"></a>**AP-06** | **Return types MUST NOT narrow** in non-MAJOR releases. Widening (returning a more permissive type, e.g., `Iterable[T]` where the API said `list[T]`) is breaking when consumers depend on list methods. Pin the contract narrowly enough that widening is rare. |
| <a id="AP-07"></a>**AP-07** | **Exceptions raised by public functions are part of the public API.** They MUST be documented and subclasses of the project's typed hierarchy. Adding a new exception type for an existing failure mode is breaking; refining the human-readable message is not. |
| <a id="AP-08"></a>**AP-08** | **Public modules MUST use `from __future__ import annotations`.** Runtime-unused imports go in `if TYPE_CHECKING:` blocks. This decouples the runtime import graph from the type-only one. |
| <a id="AP-09"></a>**AP-09** | **A PEP 561 `py.typed` marker MUST exist** for any package that ships type annotations. Without it, downstream `mypy --strict` cannot consume the package. |
| <a id="AP-10"></a>**AP-10** | **A `PUBLIC_API.md` file MUST list every public name with its stability tier and a one-line description.** It is the contract document — what consumers can depend on. The list MUST stay in sync with `__all__` (CI check). |
| <a id="AP-11"></a>**AP-11** | **Stability tiers MUST be declared** per public name: `experimental`, `stable`, `deprecated`. New names start `experimental` (one MINOR cycle to incubate); `stable` is the default consumer expectation; `deprecated` triggers `DeprecationWarning`. |
| <a id="AP-12"></a>**AP-12** | **Breaking changes MUST be documented in the CHANGELOG** with migration guidance — the old shape, the new shape, and a transformation recipe. "Removed X. Use Y instead." with a code snippet is the minimum. |
| <a id="AP-13"></a>**AP-13** | **A `__version__` attribute MUST be present** on the top-level package and MUST mirror `pyproject.toml::project.version`. CI MUST lint that the two stay in sync. |
| <a id="AP-14"></a>**AP-14** | **Internal modules MUST NOT be imported by consumers.** Names prefixed with `_` (single-underscore module names like `_identity.py`, `_settings.py`) are private. CI MUST enforce via `import-linter` that consumer test fixtures only touch the public surface. |
| <a id="AP-15"></a>**AP-15** | **Backward-compatibility shims that survive removal of the original code MUST emit `DeprecationWarning` AND log at INFO once per process.** Silent shims rot — operators don't know they're depending on a deprecated path until removal day. |
| <a id="AP-16"></a>**AP-16** | **Federation siblings MUST pin the `mgf-common` cornerstone with the wide window `>=<floor>,<1.0` (not a single MINOR), backed by a mandatory compatibility-matrix CI.** The tight-pin default (§4.3) is relaxed *only* for the cornerstone dependency: the per-MINOR fleet pin-walk tax is real (PAPER-04) and the safety a tight bound gives is instead provided by the matrix — every `mgf-common`-consuming sibling MUST run its suite against at least `{floor, current-cornerstone}` `mgf-common`, so a real break reds CI, and *that* (not a routine MINOR) is when a sibling re-tightens its floor + releases. The cornerstone's release pipeline MUST ping siblings' matrix on a new MINOR so a break surfaces before consumers pull the untested combo. Unrelated third-party 0.x deps still follow §4.3. See §4.4. |

---

## 1. Mental model — the API as a contract

A library's public API is a **contract** between you (the author)
and every consumer. Once a consumer pins your version range and
ships, every public name they import is a promise:

- The name will exist in every release inside the pin range.
- Calling it with the documented signature will produce the
  documented result.
- Errors raised will be of the documented types.
- Performance characteristics will not regress beyond reasonable
  bounds.

Breaking that contract is not a refactor — it's a **breach**. The
cost falls on every consumer, multiplied by their own consumers, in
proportion to how silently the break manifests (a loud `ImportError`
is cheap; a quiet semantic change is catastrophic).

The standard below is the discipline that makes contracts possible.

### 1.1 The two-population view

Every public symbol has two populations of users:

- **Visible users** — code in your own monorepo, your reference
  consumer, examples in this very document. Easy to find with
  `grep`. Easy to update.
- **Invisible users** — every project on the internet that pinned
  your version range and shipped. Impossible to enumerate.
  Impossible to update.

The standard's job is to keep the invisible-user cost predictable.

### 1.2 The asymmetry that makes 1.0 the dividing line

Pre-1.0 (`0.x`):
- Invisible users are few; the ecosystem is still forming.
- API churn cost is low; the lesson learned is high.
- MINOR versions MAY break the API ([SemVer 0.x clause](https://semver.org/#spec-item-4)).

Post-1.0 (`1.x`+):
- Invisible users multiply.
- Each break is a coordination exercise across the ecosystem.
- MAJOR is the only legal place to break.

The 0.x window is the gift you get exactly once. Use it for the
hard re-shapes; lock in at 1.0 only when you are sure.

---

## 2. The public surface

### 2.1 `__all__` is the contract list (rule AP-01)

Every package `__init__.py` and every module that consumers import
from declares `__all__`:

```python
# src/<app>/observability/__init__.py
"""Observability: logging, tracing, crash reporting.

Public API. Names not in __all__ are internal and may change in
any release.
"""

from __future__ import annotations

from <app>.observability.crash_reporter import report_now
from <app>.observability.excepthook import install_excepthook
from <app>.observability.logging_setup import LogFormat, setup_logging
from <app>.observability.otel_setup import operation_span, setup_otel
from <app>.observability.redaction import Redactor
from <app>.observability.threading_excepthook import install_threading_excepthook

__all__ = [
    "LogFormat",
    "Redactor",
    "install_excepthook",
    "install_threading_excepthook",
    "operation_span",
    "report_now",
    "setup_logging",
    "setup_otel",
]
```

The list MUST be:

- **Sorted alphabetically.** The order has no semantic meaning;
  alphabetic sort makes diffs minimal.
- **Strings only.** No `__all__ = list(globals())` shenanigans —
  explicit names so the contract is grep-able.
- **Verified in tests.** A unit test asserts `__all__` matches
  what's importable from the package.

### 2.2 Private modules, public re-exports

The internal layout of a package MUST be invisible to consumers.
The convention:

- `src/<app>/foo/__init__.py` re-exports the public names.
- `src/<app>/foo/_impl.py` (single-underscore prefix) holds the
  implementation. Consumers MUST NOT import `_impl` directly.

```python
# src/<app>/foo/__init__.py
from <app>.foo._public import bar, baz
__all__ = ["bar", "baz"]
```

```python
# src/<app>/foo/_public.py — the implementation, prefixed
def bar() -> int: ...
def baz() -> str: ...
```

This shape lets you refactor the internal layout (split `_impl.py`
into `_helpers.py` + `_engine.py`, etc.) without breaking
consumers. Only the names re-exported through `__init__.py`'s
`__all__` are the contract.

`mgf-common` follows this exactly — `src/mgf/common/_identity.py`
is private; the public names are `mgf.common.app_name` /
`mgf.common.app_version` re-exported through `__init__.py`, and
the cache is populated by `mgf.common.bootstrap(...)` (which calls
the internal `_identity._set_identity` helper).

### 2.3 Test-only imports

Consumer test fixtures sometimes need access to internal helpers
(a private utility makes test setup easier). Two options:

1. **Promote the helper** to the public surface if it's genuinely
   useful to consumers. Add it to `__all__`, document, ship.
2. **Provide a `<app>.testing` public submodule** with helpers
   designed for test use. The submodule's `__all__` is the public
   testing-API contract.

Importing `<app>._private` from a consumer test is a defect — the
helper may disappear in any release.

### 2.4 The `PUBLIC_API.md` document (rule AP-10)

Every published library MUST ship a `PUBLIC_API.md` (or
`docs/PUBLIC_API.md`) that lists every public name with its
stability tier and a one-line description. The format:

```markdown
# Public API — <app>

Snapshot of every public name, with stability tier. Names listed
as `experimental` MAY change in any MINOR release. Names listed as
`stable` follow the deprecation cycle (rule AP-04). Names listed
as `deprecated` MUST be removed in a future MAJOR release.

## `<app>`

| Name | Tier | Since | Description |
|---|---|---|---|
| `configure(app_name, app_version)` | stable | 0.1.0 | Set process-wide app identity for observability functions |
| `app_name() -> str` | stable | 0.1.0 | Read the configured app name |
| `app_version() -> str` | stable | 0.1.0 | Read the configured app version |
| `__version__: str` | stable | 0.1.0 | Package version string |

## `<app>.exceptions`

| Name | Tier | Since | Description |
|---|---|---|---|
| `AppError` | stable | 0.1.0 | Base for every typed exception in the project's hierarchy |
| `ConfigError` | stable | 0.1.0 | Category base for configuration / spec failures |
| ...
```

A CI check enforces that every name in `__all__` has a row in
`PUBLIC_API.md` and that no row references a name not in `__all__`.
The pin is intentionally substring-based against the doc text —
it tolerates wording changes around the name (signature shape,
type hints, prose) but fails loudly if a name disappears from the
doc entirely:

```python
# tests/unit/test_public_api_doc.py
from pathlib import Path

import <app>
import <app>.exceptions  # one explicit import per public submodule
# ...

PUBLIC_API_PATH = Path(__file__).resolve().parent.parent.parent / "PUBLIC_API.md"


def _public_names() -> list[tuple[str, str]]:
    """Yield (module_label, name) for the project's public surface."""
    out: list[tuple[str, str]] = []
    for module in (<app>, <app>.exceptions, <app>.config, ...):
        for name in getattr(module, "__all__", ()):
            out.append((module.__name__, name))
    return out


def test_public_api_doc_lists_every_export() -> None:
    """AP-10: PUBLIC_API.md MUST list every name in __all__."""
    text = PUBLIC_API_PATH.read_text(encoding="utf-8")
    for module_label, name in _public_names():
        assert name in text, (
            f"AP-10: {module_label}.{name!r} is in __all__ but "
            f"missing from PUBLIC_API.md"
        )
```

The substring check is deliberate. A structured parser is more
"correct" in principle but brittle in practice — every refactor
of the markdown table format becomes a CI break. The "name
appears somewhere in the doc" pin catches the failure mode the
rule exists to prevent (a public name shipping with no
documentation), without false-positiving on benign wording
edits.

### 2.5 The machine-readable companion: `PUBLIC_API.json`

A `PUBLIC_API.md` is for humans; downstream tooling (consumer-side
catch-up scripts, IDE plugins, codemod helpers) wants structured
data. Ship both:

```
PUBLIC_API.md    ← human-readable contract document
PUBLIC_API.json  ← machine-readable companion with the same surface
```

The JSON file enumerates every name + tier + since-version + a
short description, plus the package's own `__version__` so a
consumer can detect "this snapshot is for v0.X" without parsing
the markdown. Generate it from a small CLI subcommand
(`mgf-fed explain --json`, or equivalent) so the two files
stay in sync mechanically.

```json
{
  "__version__": "0.33.0",
  "modules": {
    "<app>": {
      "names": [
        {"name": "configure", "tier": "stable", "since": "0.1.0",
         "description": "Set process-wide app identity"},
        ...
      ]
    },
    "<app>.exceptions": { ... }
  }
}
```

**Tracker discipline (`RA-10`).** A `PUBLIC_API.json` that's
hand-edited drifts the moment a public name moves. The
discipline mgf-common settled on (and the standard
recommends): regenerate `PUBLIC_API.json` from
`__all__` at every release, and pin in CI that
`PUBLIC_API.md` mentions every name in
`PUBLIC_API.json`. Two pins — one for human-doc drift, one for
machine-companion drift — catches both failure modes.

---

## 3. Stability tiers (rule AP-11)

Every public name has one of three tiers:

| Tier | Lifetime contract | Marker |
|---|---|---|
| **`experimental`** | MAY change shape, signature, or behaviour in any MINOR release without prior deprecation. Use at your own risk; pin tightly. | `# tier: experimental` comment + tier column in `PUBLIC_API.md` |
| **`stable`** | Follows the AP-04 deprecation cycle. New parameters MUST be keyword-only with defaults. Removed only in MAJOR. | (default — no marker) |
| **`deprecated`** | Slated for removal in a future MAJOR release. Calls MUST emit `DeprecationWarning`. The replacement MUST be documented. | `@deprecated(...)` decorator (Python 3.13+) or manual `warnings.warn` + tier column |

### 3.1 The `experimental` tier

New public names start as `experimental` for one MINOR cycle
before being promoted to `stable`. The cycle gives the maintainer
one round of consumer feedback (via [`FEEDBACK.md`](../../FEEDBACK.md))
before the API is locked.

Promotion: bump from `experimental` → `stable` in the next MINOR
release. The `PUBLIC_API.md` row updates the tier column; no code
change.

If feedback indicates the shape is wrong, the maintainer MAY
re-shape the name in MINOR and document the break in.
Consumers using `experimental` names accepted that risk.

### 3.2 The `stable` tier

The default. Once promoted, the API is in the contract — see
rules AP-03 through AP-07 for what "stable" promises. The path
out of `stable` is only via deprecation → removal in a future
MAJOR release.

### 3.3 The `deprecated` tier

A name that the project wants to remove. The shape:

```python
import warnings

def old_function(*args, **kwargs):
    """Compute something. .. deprecated:: 0.5.0
       Use :func:`new_function` instead.
    """
    warnings.warn(
        "old_function is deprecated since ; use new_function instead. "
        "It will be removed in v1.0.",
        DeprecationWarning,
        stacklevel=2,
    )
    return new_function(*args, **kwargs)
```

`stacklevel=2` makes the warning point at the caller, not at this
file — the consumer sees their own line in the trace.

### 3.4 The new `@deprecated` decorator (Python 3.13+)

PEP 702 adds a stdlib `@warnings.deprecated` decorator. When the
project's minimum Python is 3.13+, prefer it:

```python
from warnings import deprecated

@deprecated("use new_function instead; will be removed in v1.0")
def old_function(*args, **kwargs):
    return new_function(*args, **kwargs)
```

Tooling (PyCharm, mypy 1.11+, pyright) surfaces the deprecation in
the IDE. Until 3.13 is the minimum, manual `warnings.warn` is the
portable shape.

### 3.5 The deprecation timeline

| Step | Release | What |
|---|---|---|
| **Announce** | | Mark deprecated; emit `DeprecationWarning`; add row to CHANGELOG `[Deprecated]`; document replacement |
| **Survive** | | Still works; warning still fires; CHANGELOG re-mentions the upcoming removal |
| **Remove** | v1.0.0 (MAJOR) | Name deleted; `ImportError` for consumers who didn't migrate; CHANGELOG `[Removed]` row |

The minimum is one MINOR cycle of survival between announce and
remove. Most projects MUST give two cycles, especially for widely-
depended-on names.

---

## 4. SemVer rigor (rule AP-03)

### 4.1 The summary

| Bump | When | Examples |
|---|---|---|
| **MAJOR** (`X+1.0.0`) | Public-API break | Remove a public function; narrow a return type; rename a parameter; raise a new exception type from an existing function; change default behaviour |
| **MINOR** (`X.Y+1.0`) | Backward-compatible addition | Add a new public function; add a new keyword-only parameter with default; widen an accepted input type; raise a new exception that's a subclass of an already-documented one |
| **PATCH** (`X.Y.Z+1`) | Backward-compatible bug fix | Fix a bug in an existing function; improve performance; tighten an internal helper; doc fix |

### 4.2 What counts as breaking — comprehensive

| Change | Breaking? | Notes |
|---|---|---|
| Remove a name from `__all__` | Yes | Even if the name still exists, consumers who follow `__all__` lose it |
| Remove an entire module | Yes | `ImportError` |
| Rename a public function | Yes | Use alias + deprecation |
| Add a required parameter to a public function | Yes | Existing call sites raise `TypeError` |
| Add a keyword-only parameter with a default | No | Existing call sites unaffected |
| Add a positional parameter (even with default) | Maybe | Yes if existing call sites pass later positional args; the safe shape is keyword-only |
| Change a parameter's name | Yes | Keyword callers break (`func(old=x)` → `TypeError`) |
| Change a parameter's default value | Yes | Behaviour changes silently — strictly breaking |
| Narrow a parameter's accepted types | Yes | Existing inputs may now reject |
| Widen a parameter's accepted types | No | Existing inputs still accepted |
| Narrow a return type | Yes | Existing consumers may rely on the wider type's methods |
| Widen a return type | Yes (subtle) | E.g., `list[T]` → `Iterable[T]` breaks `result[0]` callers |
| Change return value semantics | Yes | Even if type is the same |
| Add a new exception type to a function | Yes | Existing `try / except OldError:` doesn't catch the new one |
| Add a new exception that's a subclass of one already raised | No | The category catch still works |
| Change an exception's message wording | No | Messages are not part of the contract (but: pin actionable hints — see `ERROR_HANDLING.md` §14.7) |
| Change exception field names | Yes | Consumers reading `e.field_name` break |
| Change a class's MRO (parent class set) | Yes | `isinstance` checks may flip |
| Reorder fields in a frozen dataclass | Yes | Positional construction breaks |
| Add a field to a frozen dataclass | Yes if positional; No if keyword-only with default | Use `field(kw_only=True)` for safety |
| Change a `Literal[...]` to a wider type | Yes | Static checkers stop catching invalid values |
| Drop support for an older Python version | Yes (MAJOR) | Even though `pip install` will pick the right version |
| Bump a required dependency's MAJOR | Yes (MAJOR) | Consumer environments may not support the new dep version |
| Tighten a dependency's lower-bound version | Maybe | Yes if older version still in field use |
| Performance regression beyond documented budget | Yes | If the budget is in `PUBLIC_API.md` or a topic doc |

The rule of thumb: **if a working consumer in the wild can stop
working after the upgrade, the change is breaking.**

### 4.3 The 0.x exception

Pre-1.0, MINOR releases MAY break the API. This is the
[SemVer 0.x clause](https://semver.org/#spec-item-4). Consumers
pinning a 0.x library MUST use a tight range:

```toml
# Right (consumer-side)
mgf-common = ">=0.1.0,<0.2"

# Wrong for an arbitrary consumer — 0.x can break in any MINOR
# (federation siblings are the exception — see §4.4 / AP-16)
mgf-common = ">=0.1.0,<1.0"
```

`mgf-common` itself ships with this exact contract: the
0.x window exists to make API churn possible while early consumers
shape the surface. Once at 1.0, the standard MAJOR/MINOR/PATCH
discipline applies.

> **Federation exception (AP-16).** The tight bound above is the default
> for an *arbitrary* 0.x consumer. `mgf-*` siblings consuming the
> `mgf-common` cornerstone instead pin the wide window `>=<floor>,<1.0`:
> the safety a tight bound gives (a breaking MINOR can't be pulled) is
> provided instead by a compatibility-matrix CI (a breaking MINOR reds the
> sibling's CI), which drops the per-MINOR fleet pin-walk tax without
> losing the guard. See §4.4.

### 4.4 The federation pin-window (rule AP-16)

**The tax.** Under §4.3's tight pin, every `mgf-common` MINOR — even a
docs-only one touching nothing a sibling imports — forces a
federation-wide release wave: each of ~27 siblings pins `mgf-common<X.Y`,
so all must publish a pin-widening release before a consumer can adopt the
new cornerstone. ~30 min of operator time per MINOR for, usually, zero
real incompatibility (PAPER-04: mgf-http's surface against `mgf-common` —
`AppError`, `OperationError`, observability hooks, `Settings` types — was
unchanged across 0.36 → 0.49).

**The window.** A sibling pins `mgf-common>=<floor>,<1.0` — the whole 0.x
line above its floor. No upper-bound walk until `mgf-common` 1.0.

**The safety net (mandatory — the wide pin is invalid without it).** A
wide pin is only honest if a real break is caught before it reaches a
consumer. Every `mgf-common`-consuming sibling MUST carry a
**compatibility-matrix CI job** that resolves + runs its suite against, at
minimum, `{floor, current-cornerstone}` `mgf-common` (ideally + latest):

```yaml
  - name: compat-matrix
    image: python:3.12-slim
    matrix:
      MGF_COMMON: ["<floor>", "<cornerstone>"]
    commands:
      - pip install --index-url https://195.15.203.250.sslip.io/magogi/prod/+simple/ "mgf-common==${MGF_COMMON}.*"
      - pip install -e ".[dev]"
      - pytest -q
```

A red means the sibling's import surface actually broke against a
`mgf-common` version *in its window* — and *that* is when the sibling
re-tightens its floor + cuts a release. A release is paid on a real break,
not every MINOR.

**Cross-repo trigger (B-lite).** The cornerstone's release pipeline MUST
ping every sibling's compat-matrix on a new MINOR — a Woodpecker
`POST …/repos/<sibling>/pipelines` per sibling, driven by an org CI token
(AG-20 / `MGF_INFRA.md`) — so a break surfaces in CI *before* a consumer's
`uv sync` can pull the untested combo. Reference: `scripts/ping_sibling_ci.py`
(reads the sibling list from the REL-13 registry; skips the cornerstone
itself).

**The residual risk (accepted).** Between a `mgf-common` MINOR shipping and
the ping completing, a consumer could pull an untested combo. The ping
closes this to minutes; a sibling's own next CI run closes it fully. The
federation accepts this narrow window as the price of dropping the
per-MINOR tax; the aspirational end-state — Approach C, a
`federation-windows.toml` consumers read to derive pins with *no* sibling
release at all — is deferred unless the window ever bites.

**Rollout.** Apply the wide pin + the matrix job to every sibling as one
cohort wave ([`cohort-release-wave.md`](../recipes/cohort-release-wave.md));
the cornerstone adds the ping step. Until a sibling adopts, its narrower
pin is still conformant with §4.3 — AP-16 is the federation's forward
default, tracked to full adoption in the conformance ledgers.

---

## 5. Function signatures (rule AP-05)

### 5.1 New parameters: keyword-only with defaults

```python
# Before
def setup(level: str = "INFO") -> None: ...

# After (non-breaking)
def setup(level: str = "INFO", *, fmt: str = "auto") -> None: ...

# WRONG — adds positional, breaks `setup("INFO", "json")` callers
def setup(level: str = "INFO", fmt: str = "auto") -> None: ...
```

The `*` separator forces every new parameter to be keyword-only.
Existing positional calls are unaffected; existing keyword calls
ignore the new parameter.

### 5.2 Renames: alias + deprecation

```python
import warnings

def setup(level: str = "INFO", *, fmt: str = "auto", **kwargs) -> None:
    if "format" in kwargs:
        warnings.warn(
            "setup(format=...) is deprecated; use fmt= instead. "
            "Will be removed in v1.0.",
            DeprecationWarning,
            stacklevel=2,
        )
        fmt = kwargs.pop("format")
    if kwargs:
        raise TypeError(f"setup() got unexpected kwargs: {kwargs}")
    ...
```

The `**kwargs` catches the legacy name, warns, forwards. After
the deprecation cycle, `**kwargs` is removed and the function
takes only the canonical parameter.

### 5.3 Removal: deprecation cycle

A parameter that is no longer needed:

```python
import warnings
from typing import Any

def setup(
    level: str = "INFO",
    *,
    fmt: str = "auto",
    legacy_arg: Any = None,  # tier: deprecated; will be removed in v1.0
) -> None:
    if legacy_arg is not None:
        warnings.warn(
            "setup(legacy_arg=...) has no effect since and "
            "will be removed in v1.0.",
            DeprecationWarning,
            stacklevel=2,
        )
    ...
```

After the cycle, the parameter is removed entirely.

### 5.4 Type narrowing: forbidden in MINOR

```python
# Before
def parse(data: bytes | str) -> Result: ...

# WRONG (in MINOR) — narrows the input type
def parse(data: bytes) -> Result: ...

# RIGHT (in MAJOR) — documented break
```

Type widening of return values is also subtly breaking — see §4.2.

### 5.5 Overloads (`@typing.overload`)

When a function has genuinely different return types per input
type, use `@overload`:

```python
from typing import Literal, overload

@overload
def parse(data: bytes, *, raw: Literal[True]) -> RawResult: ...
@overload
def parse(data: bytes, *, raw: Literal[False] = False) -> Result: ...

def parse(data: bytes, *, raw: bool = False) -> Result | RawResult:
    return RawResult(data) if raw else Result.from_bytes(data)
```

Adding a new `@overload` variant is non-breaking (consumers' calls
still resolve to the existing overload). Removing one is breaking.

---

## 6. Exception contract (rule AP-07)

### 6.1 Exceptions are public API

The set of exception types a public function may raise is part of
its contract. Document in the docstring:

```python
def load_settings(cls, *, path: Path | None = None) -> T:
    """Build the Settings object from layered sources.

    Raises
    ------
    AppConfigError
        If the YAML file is malformed or the schema version is
        newer than this version of the library supports.
    SchemaValidationError
        If a typed field's value fails validation. Carries
        ``.errors`` with the complete list of validation errors.
    """
```

### 6.2 The hierarchy contract

Every exception MUST subclass the project's `AppError` base (rule
**EH-01**). Adding a new concrete subclass under an existing
category is **non-breaking** (consumer's `except CategoryError:`
still catches it). Adding a new top-level category is breaking
(no existing catch-all matches it).

### 6.3 Re-classification

Moving an exception under a different parent is breaking — even
within the project's hierarchy. A consumer's
`except ProviderError:` stops catching what was previously a
`ProviderError` if the exception is reparented under
`OperationError`.

### 6.4 Message wording

Exception message strings are NOT part of the contract — they
are human-readable for the user and the operator. Refining wording
is allowed in any release.

The exception is the **actionable hint suffix** (rule **EH-09**) —
those MUST be pinned by substring tests so they don't silently
disappear. See [`ERROR_HANDLING.md`](ERROR_HANDLING.md) §14.7.

---

## 7. Type stability (rules AP-08, AP-09)

### 7.1 `from __future__ import annotations`

Every public module (and most internal ones) MUST start:

```python
"""Module docstring."""

from __future__ import annotations

# ... imports
```

This makes every annotation a string at runtime, decoupling the
runtime import graph from the type-only one. Side benefits:

- Forward references work without quotes.
- Cyclic type-only imports go in `if TYPE_CHECKING:` blocks.
- Faster module load (no eager annotation evaluation).

### 7.2 The PEP 561 marker (`py.typed`)

Any package that ships type annotations MUST include an empty
`py.typed` file at the package root:

```
src/<app>/
├── __init__.py
├── py.typed ← REQUIRED for downstream mypy --strict
├── ...
```

Without it, `mypy --strict` on a downstream consumer reports:

```
error: Skipping analyzing "<app>": module is installed, but missing
library stubs or py.typed marker [import-untyped]
```

Followed by a cascade of "cannot subclass" errors for every
typed base class.

`mgf-common` ships `src/mgf/common/py.typed` exactly for this
reason — it's a load-bearing one-byte file. Hatchling picks it up
because `[tool.hatch.build.targets.wheel] packages = ["src/mgf"]`
walks the tree.

### 7.3 mypy strict on the public surface

The CI gate MUST run `mypy --strict` on `src/`. The strict mode
forbids:

- Untyped function definitions.
- `Any` in return types without explicit annotation.
- Implicit re-exports (every name in `__all__` must be re-exported
  with `from X import Y as Y`, OR the source module has `__all__`
  too).
- Untyped decorators.

These restrictions are PRESCRIPTIONS for type-safe library
authoring. A library that fails strict mode is, in practice,
unusable from a consumer that runs strict.

---

## 8. Module organization

### 8.1 The flat-vs-nested decision

| Public surface size | Shape | Example |
|---|---|---|
| Small (<10 names) | Single module | `<app>.exceptions` |
| Medium (10–50 names, grouped) | Package with one module per group | `<app>.config` (settings, paths, loader) |
| Large (50+ names, multi-area) | Package with sub-packages | `<app>.observability` (logging, otel, redaction, crash) |

`mgf-common` uses all three shapes:

- `mgf.common.__init__.py` — small (3 names: `configure`, `app_name`, `app_version`).
- `mgf.common.exceptions` — medium, single-package re-exporting.
- `mgf.common.observability` — medium-large, package with multiple modules.

### 8.2 Don't over-flatten

Re-exporting every name from every sub-module to the top level
("for convenience") is a defect:

- The contract surface explodes.
- Internal refactors become breaking changes.
- IDE autocompletion at the top level becomes unusable.

Consumers reach `from <app>.observability import setup_logging`,
not `from <app> import setup_logging`. The package boundary is
the unit of cohesion.

### 8.3 Don't expose third-party types

A public API that exposes `pydantic.BaseModel` subclasses or
`opentelemetry.trace.Span` types directly is locking consumers
into those dependencies' versions. Wrap or re-export carefully:

```python
# Avoid (couples consumers to a specific pydantic version)
def configure_for(settings: pydantic.BaseSettings) -> None: ...

# Better — accept the project's typed wrapper
def configure_for(settings: BaseAppSettings) -> None: ...
```

Re-exporting third-party types in `__all__` is acceptable when the
type is part of the contract (e.g., `mgf.common.exceptions.AppError`
inheriting from `Exception` — the latter is so foundational that
"depend on it" is universal).

---

## 9. Versioning the package itself (rule AP-13)

### 9.1 The single source of truth

The version string MUST be defined in **exactly one place**, and
mirrored everywhere else mechanically. The conventional shape:

```python
# src/<app>/__init__.py
__version__ = "0.1.0"
```

```toml
# pyproject.toml
[project]
name = "<app>"
version = "0.1.0" # MUST equal <app>.__version__
```

CI pin:

```python
# tests/unit/test_version_in_sync.py
import tomllib
from pathlib import Path
import <app>

def test_pyproject_version_matches_dunder():
    """AP-13: pyproject version MUST equal __version__."""
    pyproject = tomllib.loads(Path("pyproject.toml").read_text())
    declared = pyproject["project"]["version"]
    assert <app>.__version__ == declared, (
        f"version drift: __version__={<app>.__version__!r}, "
        f"pyproject={declared!r}"
    )
```

### 9.2 Reading the version

Consumers read `<app>.__version__`. Production code that needs the
version (logging, crash report, OTel resource) reads it from there
— never duplicates the string.

### 9.3 Pre-release and build metadata

| Form | When |
|---|---|
| `0.1.0` | Stable release |
| `0.1.0a1` / `0.1.0b1` / `0.1.0rc1` | Alpha / beta / release candidate |
| `0.1.0.dev0` | Untagged development build |
| `0.1.0+local.<hash>` | Local development with build metadata |

Use only `0.1.0`-style for published releases (uploaded to the
federation devpi index — see
[`PACKAGE_DISTRIBUTION.md`](PACKAGE_DISTRIBUTION.md) PKG-01/PKG-04).
The `.dev` and `+local` forms are for local installs and CI test
artefacts.

---

## 10. The CHANGELOG contract (rule AP-12)

Follows [Keep a Changelog](https://keepachangelog.com/) with one entry per release:

```markdown
## [Unreleased]
### Added
- `setup_otel(*, enabled=None)` — initialise OTel SDK + OTLP exporter.

## [0.2.0] — 2026-05-15
### Added
- ...

### Changed
- **BREAKING:** `load_settings` now requires `settings_cls` as the
  first positional arg. Migration:
  ```python
  # Before
  cfg = load_settings(path=p)
  # After
  cfg = load_settings(MySettings, path=p)
  ```

### Deprecated
- `setup(format=...)` keyword. Use `setup(fmt=...)`. Will be
  removed in v1.0.

### Removed
- (none in this release)

### Fixed
- ...

### Security
- ...
```

### 10.1 The migration recipe (rule AP-12)

Every entry under `### Changed` that is breaking MUST include:

1. The **exact symptom** a consumer will see ("`TypeError: load_settings() takes 0 to 1 positional arguments but 1 was given`").
2. The **before** code (the working pre-change shape).
3. The **after** code (the working post-change shape).
4. **One sentence on why** ("the old shape couldn't carry the
   subclass type so callers had to ignore type errors").

A `### Changed: BREAKING` entry without these four pieces is
incomplete — consumers face the symptom without a path forward
and open issues at the maintainer.

### 10.2 The `### Removed` section

Names removed in this release. Each MUST cite the version that
deprecated it:

```markdown
### Removed
- `setup(format=...)` keyword (deprecated in 0.5.0). Use `setup(fmt=...)`.
- `<app>.legacy_module` (deprecated in 0.7.0). Use `<app>.new_module`.
```

A removal of a name not previously deprecated is a defect — every
removal MUST go through the deprecation cycle (rule AP-04).

---

## 11. Backward-compatibility shims (rule AP-15)

A "shim" is a layer that keeps an old name working while the
internals move. Common shapes:

```python
# A re-export from a new location to the old location
# src/<app>/old_module.py
import warnings
from <app>.new_module import the_function

warnings.warn(
    "<app>.old_module is deprecated; import from <app>.new_module instead. "
    "Will be removed in v1.0.",
    DeprecationWarning,
    stacklevel=2,
)

__all__ = ["the_function"]
```

**MUSTs:**

- The shim emits `DeprecationWarning` on **import**, not just on
  use — so even `import <app>.old_module` (without calling the
  function) tells the consumer the path is deprecated.
- The shim logs at INFO **once per process** with `audit=true`:

  ```python
  LOG.info(
      "deprecated import path used",
      extra={
          "audit": True,
          "deprecated_module": "<app>.old_module",
          "replacement": "<app>.new_module",
      },
  )
  ```

  This makes the deprecation visible in production logs, not just
  in dev-time warning runs (production typically suppresses
  warnings).

- The shim has a **removal date** in the comment + CHANGELOG:

  ```python
  # tier: deprecated; remove in v1.0 (announced 0.5.0)
  ```

The canonical shim shape in this ecosystem is a consumer-side
`<app>.observability` package that re-exports from
`mgf.common.observability` so existing call sites keep working
unchanged. The shim itself is small; the re-exports are
explicit (no `from ... import *`).

---

## 12. Anti-patterns (forbidden)

| Anti-pattern | What's wrong | Right shape |
|---|---|---|
| `__all__ = list(globals())` | Contract is whatever the file happens to define | Explicit string list |
| Public name without docstring | Consumers can't tell what it does | One-line intent + invariants + example |
| Adding required positional parameter to `stable` function | Existing call sites break | Keyword-only + default |
| Renaming a parameter without an alias | `func(old=x)` → `TypeError` | Alias + deprecation cycle |
| Removing a name without prior deprecation | `ImportError` for consumers who didn't see notice | Announce → survive → remove |
| `try / except: pass` around a deprecation warning | Hides the user's signal | Let warnings propagate |
| Calling `warnings.warn` without `stacklevel=2` | Warning points at the deprecated function, not the caller | `stacklevel=2` |
| Public API exposing third-party model types directly | Consumers couple to that dep's version | Wrap in project's own type |
| Re-exporting every internal name to the top-level package "for convenience" | Surface explodes; refactors become breaking | Each public name has one canonical path |
| Bumping MAJOR and reusing version numbers | A published version is immutable by policy (PKG-07); consumers who already resolved that version would see the same number with different code | Always increment |
| `0.x → 1.0` without consumer feedback round | The 1.0 lock-in is the wrong shape | Use [`FEEDBACK.md`](../../FEEDBACK.md); resolve all 🔴 Blockers first |
| Documenting the API in a code comment "in spirit" | Consumers can't grep for it | `PUBLIC_API.md` is the contract |
| `__version__` defined in two places | Drift inevitable | One source + a pin test |

---

## 13. Self-review checklist (every public-API change PR)

Before requesting review, the author MUST be able to answer YES:

1. Is every new public name in `__all__`?
2. Is every new public name in `PUBLIC_API.md` with a tier?
3. New names start `experimental` for at least one MINOR cycle?
4. If renaming a parameter / function: is there an alias +
   `DeprecationWarning`?
5. If removing a name: was it deprecated in a prior release with
   the timeline documented in CHANGELOG?
6. Did I run `mypy --strict src` and see zero diagnostics?
7. Is `__version__` updated in BOTH `pyproject.toml` and
   `<app>/__init__.py`?
8. Does the CHANGELOG `[Unreleased]` block describe this change with
   the four migration-recipe pieces (symptom / before / after /
   why)?
9. If this is a breaking change: am I bumping MAJOR (or MINOR for
   0.x with a notable break)?
10. Is the public docstring for the changed name updated?
11. If adding a new function: does it raise typed exceptions
    documented in the docstring?

---

## 14. Mechanical enforcement matrix

| Rule | Mechanical check |
|---|---|
| AP-01 `__all__` enumerated | Custom test: every public name accessible at the package boundary is in `__all__` |
| AP-02 docstrings | Enforced by **discipline + review** (rule DP-07) + the `PUBLIC_API.md` coverage pin — NOT by ruff `D` (pydocstyle is intentionally off: it is noisy on the internal `_`-helpers that don't need a public docstring). A project that wants mechanical enforcement may add `D` to `select` with a tests/internal `per-file-ignores`. |
| AP-03 SemVer | `pyproject.toml` enforces; bumping rules are review-only but `[Unreleased]` curation discipline is the proxy |
| AP-04 deprecation cycle | `tests/unit/test_deprecations.py` assert every `DeprecationWarning` text mentions the removal version |
| AP-05 stable signatures | `mypy --strict` on consumers; one-shot API-snapshot tool ([`apispec`](https://pypi.org/project/apispec/) or hand-rolled JSON dump) compared between releases |
| AP-06 return-type stability | Same as AP-05 |
| AP-07 exception contract | code review at exception-raising changes; pin tests on the typed hierarchy |
| AP-08 `from __future__ import annotations` | `ruff` rule `FA100`/`FA102` |
| AP-09 `py.typed` marker | CI: `unzip -l dist/*.whl | grep py.typed` post-build |
| AP-10 `PUBLIC_API.md` in sync with `__all__` | `tests/unit/test_public_api_doc.py` (template in §2.4 above) |
| AP-11 stability tiers declared | Pin test reads `PUBLIC_API.md` tier column; refuses unknown tier values |
| AP-12 CHANGELOG migration recipes | code review; optional: a custom check that every `### Changed: BREAKING` entry contains "Before" and "After" code blocks |
| AP-13 `__version__` in sync with `pyproject.toml` | `tests/unit/test_version_in_sync.py` (template in §9.1) |
| AP-14 internal modules not consumer-imported | `import-linter` contract: `tests/` and downstream consumers MUST NOT import `_<name>` modules |
| AP-15 deprecated shims emit warning + audit log | code review + a pin test that `import <app>.shim_module` raises `DeprecationWarning` (use `pytest.warns`) |

---

## 15. AS-IS in `mgf-common`

- **`__all__` enumeration (AP-01):** ✅ Every public package has an
  explicit `__all__` (`mgf.common`, `.exceptions`, `.config`,
  `.observability`).
- **Docstrings (AP-02):** ✅ Every public name has a docstring;
  rule **DP-07** has prescribed this since v1.0 of the standards.
- **SemVer (AP-03):** ✅ At ; the 0.x window applies.
- **Deprecation cycle (AP-04):** N/A yet (no deprecations through
  ).
- **`from __future__ import annotations` (AP-08):** ✅ Every
  module.
- **`py.typed` marker (AP-09):** ✅ At `src/mgf/common/py.typed`;
  the wheel ships it (Phase 3 critical fact in `docs/claude/CLAUDE.md`).
- **`PUBLIC_API.md` (AP-10):** ❌ **GAP.** The contract is
  enumerated in [`README.md`](https://codeberg.org/magogi-admin/mgf-common/src/branch/main/README.md) and
  [`docs/claude/CLAUDE.md`](../claude/CLAUDE.md) §"Public API" but no canonical
  `PUBLIC_API.md` exists at the repo root. **Action:** create one
  before .
- **Stability tiers (AP-11):** Implicit (everything published in
  0.1.0 is informally `experimental` per the 0.x convention).
  Make explicit when `PUBLIC_API.md` lands.
- **CHANGELOG migration recipes (AP-12):** ✅ The `[Unreleased]`
  block uses the Keep a Changelog headings; no breaking changes
  through .
- **`__version__` in sync (AP-13):** ✅ — but pin test missing.
  **Action:** add `tests/unit/test_version_in_sync.py`.
- **Internal modules un-imported (AP-14):** ✅ The
  `mgf-common-is-the-federation-leaf` `import-linter` contract
  is the leaf-side guarantee; the consumer-side guarantee is
  enforced in each consumer's own layered contracts.
- **Backward-compat shims (AP-15):** consumer-side shims (a
  `<app>.observability` package that re-exports from
  `mgf.common.observability` so existing call sites keep working)
  are the established pattern. Whether such a shim emits
  `DeprecationWarning` is a per-consumer call: a known
  transitional layer with a planned removal target MAY omit the
  warning until the removal window opens.

---

## 16. Cross-references

- The PEP 420 namespace package shape that lets `mgf` accommodate
  multiple sibling packages: [`README.md`](https://codeberg.org/magogi-admin/mgf-common/src/branch/main/README.md)
  §"Layout" + [`docs/claude/CLAUDE.md`](../claude/CLAUDE.md) Cross-cutting
  pattern §1.
- The deprecation pattern for config field renames (CF-11):
  [`CONFIGURATION.md`](CONFIGURATION.md) §16.
- Exception hierarchy stability discipline:
  [`ERROR_HANDLING.md`](ERROR_HANDLING.md) §2.
- Reproducible builds (rule SC-18) underwriting versioning:
  [`SECURITY_STANDARD.md`](SECURITY_STANDARD.md) §10.
- Test patterns for API stability (mirror tests, public-API doc
  sync test, version-in-sync test):
  [`TESTING.md`](TESTING.md) §10.

---

*End of `API_DESIGN.md`.*
