# FEEDBACK.md — Consumer feedback & open roadmap

> **Living document.** Design conversations between
> `mgf-common` maintainers and library consumers.
>
> **Format.** Each entry is a uniform block: identifier, status
> badge, severity, structured frontmatter, then prose. The format
> is optimised for both human readers and AI agents — every entry
> has the same shape, every status field is grep-able.
>
> **Bar.** Industry-standard. The shape any serious Python
> service / desktop / mobile project reaches for by default.

---

## §1. Process

### 1.1 Submission

A consumer adds an entry by opening a PR that edits this file.
Every entry MUST follow the **§1.4 entry template** below — the
uniform shape is what makes the document machine-actionable.

The `mgf-common feedback` CLI prints the submission flow + the
entry template ready to paste. Run it from any project that
depends on mgf-common.

### 1.2 Severity

| Level | Meaning | SLA |
|---|---|---|
| 🔴 **Blocker** | Getting it wrong requires a breaking change to fix later. MUST be decided before locking the public surface. | Triaged within the release cycle it lands in; decision recorded inline. |
| 🟡 **High** | Significant quality-of-life issue. Painful to live with, additive to add later. | Addressed as a minor / patch; no release gate. |
| 🟢 **Nice-to-have** | Real value, not urgent. | Picked up when a maintainer has capacity or a second consumer asks. |
| 💭 **Aspirational** | "Long-term where should this go" input. Not actionable today. | Kept for roadmap reviews; drives major-version planning. |

### 1.3 Status lifecycle

```
OPEN → ACCEPTED → SCHEDULED → SHIPPED
       ↓
       REJECTED   (with rationale)
       DEFERRED   (agreed in principle, not scheduled)
```

- **OPEN** — submitted, not yet triaged.
- **ACCEPTED** — maintainers agree; not yet scheduled.
- **SCHEDULED** — assigned a release.
- **SHIPPED** — landed; entry's status changes inline with a
  one-line retrospective. (We don't keep a parallel "shipped
  audit" section — the CHANGELOG carries the audit trail.)
- **REJECTED** — kept (not deleted) so future consumers don't
  re-raise.
- **DEFERRED** — agreed in principle, picked up at next roadmap
  review.

**On every status flip out of `OPEN`** (to SHIPPED, DEFERRED,
or REJECTED), the maintainer drafts a short consumer-facing
notification paragraph and routes it back to whoever filed the
entry. Per [`docs/standards/RELEASING.md`](docs/standards/RELEASING.md#7-consumer-notification-on-closure-rule-rel-09)
**REL-09**: the CHANGELOG carries the migration content; the
notification is the *delivery mechanism* that tells the
consumer their entry was addressed and what to do about it.
Without REL-09, the close-loop is silent and consumers
re-discover the resolution by accident.

### 1.4 Entry template — every new entry MUST follow this shape

```markdown
### `<AREA>-<NN>` — One-line title

| Field | Value |
|---|---|
| Status | OPEN / ACCEPTED / SCHEDULED / SHIPPED / REJECTED / DEFERRED |
| Severity | 🔴 / 🟡 / 🟢 / 💭 |
| Submitter | <Project> (commit `<sha>` if applicable) |
| Submitted | YYYY-MM-DD |
| Decision | pending / accepted / rejected (one line) |
| Decided on | YYYY-MM-DD or `—` |
| Scheduled for | release tag or `—` |
| Shipped in | commit SHA + release tag, or `—` |

**Concern.** What's wrong or missing. One paragraph.

**Why it matters.** Why this affects real consumers. One paragraph.

**Concrete evidence.** File paths, line numbers, or call counts
where the pain is observable in real code today. Optional for
💭 aspirational entries; expected for 🟡 High and above. A
submission that says "consumers do X" without naming the call
sites takes longer to triage and is harder to size.

**Suggested shape.** Concrete proposal — code snippet, prose, or
both. Not required to be the final answer; required to be a
starting point.

If the design has a real choice between approaches (e.g., enum
field vs typed-subclass tree; sync vs async; bundled vs
split), prefer **Approach A vs Approach B** framing with your
recommendation. Naming both alternatives speeds triage by giving
the maintainer something to evaluate against — a single
proposal can only be accepted or rejected, but two proposals
invite a comparative decision. PAPER-11 is the canonical
example.

**Decision rationale.** Filled when triaged. Why accepted /
rejected / deferred / scheduled.

**Retrospective.** Filled when shipped. Did the suggested shape
survive contact with implementation? What changed and why?
```

The frontmatter table is the single source of truth for status.
Prose comes after.

### 1.5 Ground rules

- **API stability is best-effort during the 0.x window.** Pinning
  to a minor (e.g., `>=0.X,<0.Y`) opts the consumer in to a
  predictable change cadence; pinning broadly opts in to faster
  iteration. Aggressive 🔴 Blocker resolution is the point of
  the early window.
- **A "co-developed" consumer carries less signal than a remote
  one.** A concern raised independently by a consumer with no
  prior coordination is a stronger lift signal than the same
  concern surfaced by the maintainer's primary consumer. Filter
  accordingly.
- **Don't optimise for `mgf-common` developers.** Every design
  trade-off favours the consumer. A feature that saves the
  library author an hour but costs every consumer a day of
  integration pain is a net loss.

---

## §2. Open feedback (active)

Sorted by area, then ID. To add a new entry, copy §1.4's template
verbatim and fill it in.

### 🟡 Papercuts

#### `PAPER-03` — Observability shim pattern is load-bearing but undocumented

| Field | Value |
|---|---|
| Status | ✅ SHIPPED |
| Severity | 🟡 High (doc, not code) |
| Submitter | Reference consumer |
| Submitted | 2026-04-23 |
| Decision | accept (recipe form rather than standards-doc section) |
| Decided on | 2026-05-01 |
| Scheduled for | shipped in-tree |
| Shipped in | `docs/public/recipes/observability_shim.md` (in-tree) + cross-link from `05_observability.md` |

**Concern.** A consumer can layer a project-local
`<project>/observability/__init__.py` re-export shim over
`mgf.common.observability` to layer in its own redactor /
formatter / handler extensions without forking `mgf.common`.
This pattern is valuable but nowhere documented as recommended
practice.

**Why it matters.** A future consumer evaluating the
architecture will either (a) miss the pattern entirely and
either fork or live with limited extensibility, or (b) discover
it by reading another consumer's source — neither is the right
onboarding shape for an industry-standard library.

**Suggested shape.** Add a section to
`docs/standards/COMMON_COMPONENTS.md` or a new
`docs/patterns/shim-layering.md` explaining: (a) why a consumer
might want a shim even without legacy call sites, (b) the
`import-linter` contract that keeps the shim from re-entangling,
(c) how to layer consumer-specific redactor extensions on top.

**Resolution.** Shipped as a recipe at
`docs/public/recipes/observability_shim.md` rather than a
standards-doc section: the standards docs declare MUSTs and
SHOULDs, while this is a recommended pattern (a recipe). The
recipe covers: why a shim is valuable for extensibility (not
just back-compat), the closed-box rule that applies inside the
shim, a layered example (custom redactor + custom log handler +
wrapper around `operation_span`), an `import-linter` contract
for the consumer side, and when NOT to use the shim.
Cross-linked from `docs/public/05_observability.md` "Related
docs".

---

#### `PAPER-04` — Schema-versioning patterns are not reusable

| Field | Value |
|---|---|
| Status | ✅ SHIPPED |
| Severity | 🟡 High |
| Submitter | Reference consumer |
| Submitted | 2026-04-23 |
| Decision | accept (suggested shape verbatim) |
| Decided on | 2026-05-01 |
| Scheduled for | shipped in-tree |
| Shipped in | `mgf.common.versioned.VersionedFileStore[T]` + `MigrateFn` (in-tree) |

**Concern.** Every sub-config file in a real consumer (hooks,
schedules, alerts, lab specs, profiles) implements the same
shape: `version: int` + unknown-key rejection + migration
callback + atomic write. The code is duplicated across many
modules because `mgf.common.config.BaseAppSettings` covers only
the top-level config file, not satellite files.

**Why it matters.** Real code duplication cost (~600 LOC in
the reference consumer alone); will replicate in every consumer.
Each instance also drifts subtly (slightly different error
wording, slightly different migration shape) — exactly the
bit-rot the standards exist to prevent.

**Resolution.** Ships as `mgf.common.versioned`, a new public
subpackage. Two public names:

- `VersionedFileStore[T]` — the generic. Constructor takes the
  pydantic schema (must set `extra="forbid"`), the
  `current_version`, an optional `migrate=` callback, and an
  optional `version_field=` (default `"version"`; the consumer
  uses both `version` and `schema_version`). Format auto-detects
  from the path suffix (`.yaml` / `.yml` / `.json`); override via
  `file_format=`. `load()` validates, routes through migrate,
  returns `T`. `save()` stamps `version_field=current_version`
  and writes atomically (temp + fsync + rename, mode 0o600).
  Errors land as `AppConfigError` with PEP 678 notes naming the
  path + offending key.
- `MigrateFn` — the type alias for the migration callback:
  `Callable[[Mapping[str, Any], int], Mapping[str, Any]]`.

Recipe at `docs/public/recipes/versioned.md`. Pinned by 28 unit
tests + 5 user-perspective tests + the `PUBLIC_API.md` /
`PUBLIC_API.json` contract.

---

#### `PAPER-06` — `docs/standards/` cites the reference consumer with file:line

| Field | Value |
|---|---|
| Status | 🟡 PARTIAL — line-range citations stripped + caveat banner added |
| Severity | 🟡 High (doc refactor) |
| Submitter | Reference consumer |
| Submitted | 2026-04-23 |
| Decision | accept partial; full restructure deferred |
| Decided on | 2026-04-30 |
| Scheduled for | full restructure: future |
| Shipped in | partial: line ranges stripped from 12 citations across 6 docs; caveat banner in `docs/standards/README.md` |

**Concern.** Some topic docs have "AS-IS in <consumer>"
paragraphs citing specific file:line locations. These rot as the
consumer evolves. When a second consumer arrives, these
paragraphs become confusing ("wait, is that the reference
example or an accident?").

**Why it matters.** The standards docs are the public face of
the project. Consumer-specific examples cluttering the
cross-project standard creates a perception that the standards
are consumer-shaped rather than industry-shaped — which
undermines the cornerstone-library positioning.

**Suggested shape (partial done).** Highest-rot-risk component
closed: 12 `*.py:NN-MM` line-range citations across
`COMMON_COMPONENTS.md`, `LOGGING.md`, `CONFIGURATION.md`,
`DESIGN_PRINCIPLES.md`, and `ERROR_HANDLING.md` replaced with
file/function references that don't drift on every refactor. A
caveat banner in `docs/standards/README.md` warns readers that
consumer-specific examples are illustrative.

**Full restructure (still open).** Move consumer-specific
paragraphs into a dedicated `docs/reference-consumer/`
subfolder. When a second consumer arrives, generalise to
`docs/reference-consumers/Project1.md` /
`docs/reference-consumers/Project2.md`. Standards stay
consumer-neutral.

---

#### `PAPER-07` — Default-path helpers (`_default_log_path`, `_crash_dir`) are private but consumers need them

| Field | Value |
|---|---|
| Status | ✅ SHIPPED |
| Severity | 🟡 High |
| Submitter | Maintainer audit (VManager integration walk, 2026-05-01) |
| Submitted | 2026-05-01 |
| Decision | accept (suggested shape verbatim) |
| Decided on | 2026-05-02 |
| Scheduled for | 0.13.0 |
| Shipped in | `mgf.common.observability.{default_log_path, default_crash_dir, default_state_dir}` (v0.13.0); underscore-prefixed names retained as deprecated aliases that emit `MgfDeprecationWarning` for one cycle |

**Concern.** Consumers writing observability-adjacent UI (a log
viewer dialog, a crash-report browser, a "show me where my
state lives" diagnostic) need to know the on-disk paths the
library writes to. Today these paths are computed by private
helpers in `mgf.common.observability`:

- `mgf.common.observability.logging_setup._default_log_path()`
  — resolves `<state>/<app>/logs/<app>.log`.
- `mgf.common.observability.crash_reporter._crash_dir()` —
  resolves `<state>/<app>/crashes/`.

The reference consumer has already had to reach into the
private surface — `vmanager/gui/windows/main_window.py:254`
imports `_default_log_path` directly — which is a real
closed-box leak the consumer had no public alternative for.

**Why it matters.** Closed-box discipline is load-bearing for
`mgf-common`'s positioning. Every consumer that needs to
surface "where do my logs live?" in their UI is going to hit
this gap. The library can either (a) keep both paths private
and force every consumer to reach into the private surface
(the worst outcome — silent erosion of the closed-box
property) or (b) promote them now while the surface is still
small.

**Suggested shape.** Promote both helpers to public, ideally
in a small dedicated module so future path helpers have a
home::

    # mgf/common/observability/paths.py — new public module
    from pathlib import Path

    def default_log_path() -> Path: ...
    def default_crash_dir() -> Path: ...
    def default_state_dir() -> Path: ...   # the parent of both

Re-export from `mgf.common.observability.__all__`. Keep the
underscore-prefixed names as deprecated aliases for one cycle
so consumers reaching into the private surface get a
deprecation warning rather than a silent break.

Pin via the existing AP-10 sync test + a public-surface test
in `tests/public_surface/test_observability_as_user.py`.

---

#### `PAPER-08` — `mgf-common catch-up` should detect closed-box leaks (private imports from `mgf.common._*`)

| Field | Value |
|---|---|
| Status | ✅ SHIPPED |
| Severity | 🟢 Nice-to-have |
| Submitter | Maintainer audit (VManager integration walk, 2026-05-01) |
| Submitted | 2026-05-01 |
| Decision | accept (suggested shape verbatim) |
| Decided on | 2026-05-05 |
| Scheduled for | 0.17.0 |
| Shipped in | `_check_closed_box_leaks` audit row in `src/mgf/common/cli/_catch_up.py` (v0.17.0). AST-walks the consumer's `src/` (skipping `tests/`, `.venv/`, `__pycache__/`, etc.); flags three leak shapes — private module import (`from mgf.common._x import …`), private name from public module (`from mgf.common.X import _y`), and direct private-module import (`import mgf.common._x`). Known private→public promotions (`_default_log_path` → `default_log_path`; `_crash_dir` → `default_crash_dir`) named inline in the warning so the audit row tells the consumer not just "stop" but "do this instead." |

**Retrospective.** Implemented per the suggested shape. The
`_is_private_module` predicate generalised the rule to any
private-segment-after-`mgf.common`, catching the
`mgf.common.observability._helpers` class of leak that a
naive `startswith("mgf.common._")` check would miss. The
`_PRIVATE_TO_PUBLIC` mapping table is the right home for
private→public migration hints — extend it whenever a future
PAPER-07-shaped promotion happens. 11 unit tests pin every
leak shape + the skip-dirs invariant.

**Concern.** `mgf-common catch-up` audits four-gate config,
pin staleness, CLAUDE.md presence, and v0.1-pattern call
sites. It does NOT audit consumer source for closed-box leaks
(`from mgf.common._x import _y` or `from mgf.common.X import
_private`). The reference consumer had one such leak that the
audit didn't surface (`_default_log_path` import in
VManager's main window) — `tests/public_surface/`'s
`test_no_private_imports.py` AST pin is exactly the right
shape, but lives in `mgf-common`'s test suite, so the
discipline isn't enforced consumer-side unless the consumer
copies it.

**Why it matters.** The closed-box rule is harder to enforce
when consumers can write `from mgf.common._foo import _bar`
and silently get away with it until the next refactor breaks
their import. Surfacing this in `catch-up` makes the rule
discoverable as part of the standard "is my project up to
spec?" workflow, rather than something the consumer has to
remember to write a test for.

**Suggested shape.** Extend `_check_legacy_patterns` (or add a
new `_check_closed_box_leaks` audit row) that AST-walks every
`*.py` under `src/` and emits a warning for each
`mgf.common._*` import or `mgf.common.X._private`
import-from. Output format same as the existing
"v0.1-pattern usage" row — list the offending paths and a
one-line suggestion. Caveat: the audit MUST NOT walk into the
consumer's own `tests/` directory, since
`tests/unit/_internal_test_helpers/` style imports are
legitimate consumer-side.

This is also the right shape to surface FEEDBACK PAPER-07 —
the audit would name the private import and PAPER-07 (when
shipped) would direct the consumer to the public alternative.

---

#### `PAPER-09` — `VersionedFileStore[T]` rejects missing-version files unconditionally; consumers with pre-versioning legacy YAMLs cannot adopt it

| Field | Value |
|---|---|
| Status | ✅ SHIPPED |
| Severity | 🟡 High |
| Submitter | VManager (Phase MGF-3 catch-up walk, 2026-05-02) |
| Submitted | 2026-05-02 |
| Decision | accept (suggested shape verbatim — single boolean) |
| Decided on | 2026-05-02 |
| Scheduled for | 0.14.0 |
| Shipped in | `VersionedFileStore[T](..., tolerate_missing_version=False)` (v0.14.0); default keeps strict-by-default; opt-in logs one WARNING via `mgf.common.versioned` and treats missing as `current_version` (skipping migrate, since there is no version to migrate from); files where the field is *present but not an int* still raise — that's a corruption signal, not a legacy file |

**Concern.** `VersionedFileStore[T].load()` raises
`AppConfigError` when the on-disk file is missing the typed
version field. That's the right default for fresh consumers, but
it locks out consumers with **pre-versioning legacy files** —
files written before the consumer adopted CF-07's `version:`
discipline, which exist in user state directories on machines
that have been running the consumer across multiple releases.

VManager's `vmanager.profiles.profile.VMProfile.from_dict` ships
a deliberate grace path: a missing `version:` is logged at
WARNING and treated as `version: 1`. The next save rewrites the
file with `version:` at the top. This is what every test in
`tests/unit/test_profiles.py::TestVMProfileVersioning` pins —
specifically `test_load_accepts_missing_version_with_warning`.
Five satellite stores in VManager migrated to
`VersionedFileStore[T]` cleanly during Phase MGF-3 of
`docs/MGF_COMMON_CATCHUP.md`; the sixth (profiles) had to be
skipped because the grace path can't go through VFS. The bespoke
manual-JSON-parse + version-handling code in `profile.py` stays.

**Why it matters.** Every long-lived consumer that adds CF-07
versioning AFTER a public release of the file format hits this.
The "I added `version: <N>` to a file format that didn't have it
before; I want users' existing files to keep loading" pattern is
exactly what VFS should make easy, and it's where every consumer
will hit the same wall the moment they adopt
`VersionedFileStore[T]` for a pre-existing file. PAPER-04's
"replicates in every consumer" reasoning applies again here.

**Suggested shape.** Add an explicit opt-in flag — name it
something like `tolerate_missing_version=False` — that, when
set, treats a missing version field as the constructor's
`current_version` (with a single WARNING log via `mgf.common`'s
own logger, not raised as an error). The consumer's migrate
callback still runs against any other version, so consumers
that mix "missing means current" with real migrations
work cleanly.

```python
store = VersionedFileStore(
    path,
    schema=ProfileFile,
    current_version=2,
    migrate=migrate,           # v1 → v2
    tolerate_missing_version=True,  # missing → v2 with WARNING
)
```

The default stays `False` — fresh consumers benefit from the
strict-by-default behaviour. The flag is the explicit
opt-in for consumers that need the grace mode.

Alternative shape: a richer `MissingVersionPolicy` enum
(`reject` / `treat_as_current` / `treat_as_v1`) if the maintainer
wants more flexibility. The simple boolean is probably enough —
"treat as current with WARNING" is the only sane non-reject
behaviour, since the only thing pre-versioning files were
(implicitly) was "the version that was current when I was
written," which becomes "the current version" once they're
loaded by a binary that knows about versioning.

---

#### `PAPER-10` — No entry-point-driven auto-discovery for observability / vault plug-ins

| Field | Value |
|---|---|
| Status | DEFERRED |
| Severity | 🟢 Nice-to-have |
| Submitter | VManager (Round 2 audit walk, 2026-05-02) |
| Submitted | 2026-05-02 |
| Decision | defer until first plugin-framework consumer files concrete pain |
| Decided on | 2026-05-02 |
| Scheduled for | — |
| Shipped in | — |

**Decision rationale.** VManager themselves admit they don't
need this today (single-tenant, no extensions registered yet).
The `[project.entry-points.*]` shape is well-understood from
pytest / pluggy / flake8, but the *grouping* and the
*lifecycle* (when in `bootstrap()` to walk; what counts as a
plugin failure) are calls that benefit hugely from a real
consumer constraining them.

Strategic-review P1 (commit `f3e5845`) is exactly this point:
"Get a second, independent consumer." The second consumer's
plugin needs are the gate for designing the entry-point seam.
Pre-designing risks calcifying wrong: a seam that goes
un-stress-tested for two years lands in v1.0 with whatever
shape we guessed today, and consumers route around it instead
of using it.

The shipping-early argument ("zero downstream registrations to
migrate") is real but weaker. The existing explicit-call API
is already additive-compatible with any future entry-point
walk; consumers that adopt entry points don't have to rewrite
their existing wiring. So the cost of waiting is small.

**Reopen criteria.** PAPER-10 is gated on the same evidence as
Strategic-Review **Priority 1** —
[*Get a second, independent consumer*](docs/internal/STRATEGIC_REVIEW.md#priority-1--get-a-second-independent-consumer).
The two are the same problem from different angles: P1 asks
"who else uses this library and what shape did their adoption
take?", PAPER-10 asks "what does that adoption shape demand of
the extension surface?". The answer to either lands evidence
for both.

Reopen with any of:

  1. A plugin-framework consumer (multi-tenancy §S1 scenario)
     files concrete pain from the explicit-call shape.
  2. A second independent consumer lands and reports their
     extension-wiring story — pain *or non-pain* both count
     as evidence (non-pain may mean explicit-call is fine and
     PAPER-10 stays deferred for longer).
  3. An in-tree consumer (`example_app/`, `mgf-apiprobe`,
     future siblings) ships a pip-installable extension that
     would benefit from auto-discovery.

Whichever channel surfaces P1's signal surfaces PAPER-10's
signal too; no separate watch is needed.

**Concern.** `mgf.common.observability` exposes five explicit
registries — `register_log_handler`,
`register_redaction_pattern_group`, `register_span_processor`,
`register_crash_sink` — and `mgf.common.vault` adds a sixth via
`register_vault_backend`. All six require the consumer to
**import the plugin module + call the registration function**
somewhere that executes BEFORE `bootstrap()` claims its wiring
slot. Miss the import (e.g., the plugin lives in a sibling
package the host doesn't know about) and the registration never
happens; miss the timing (call after bootstrap) and the wiring
silently ignores the late entry.

The contrast: `mgf.common.pytest_plugin` declares itself in
`pyproject.toml::[project.entry-points.pytest11]` and pytest
auto-discovers it. Zero consumer-side import; the plugin "just
appears." Every other observability extension point is the
opposite shape — explicit-import, explicit-call, explicit-order.

**Why it matters.** The strategic review's Priority 1 ("second,
independent consumer") leans heavily on the multi-tenancy story
documented in `docs/public/multi_tenancy.md` §S1 — plug-in
frameworks hosting child packages with their own identity and
extensions. That scenario implies third-party plug-ins ship
their own redactor / handler / span-processor. Today every such
plug-in has to convince the host to import it and call its
register hook in the right place. With Python entry points, the
plug-in declares itself; `bootstrap()` walks the entry-point
groups during wiring and the plug-in's hook runs at the right
moment with no host-side change.

VManager itself doesn't have this need today (single-tenant, no
extensions registered yet — Round 2's `R2-MGF-PROGRESS` migration
is purely a re-export shim). The audit raised the gap because
VManager's recipe-driven shim layering pattern, when applied at
scale by a plug-in framework consumer, would surface this
friction immediately. Better to design the discovery seam now —
when there are zero downstream registrations to migrate — than
after a plug-in ecosystem has accreted around the explicit-call
shape.

**Suggested shape.** Define entry-point groups, one per
registry:

```toml
# Consumer's pyproject.toml — say, "vmanager-spice-redactor"
[project.entry-points."mgf.common.observability.redaction_pattern_groups"]
spice = "vmanager_spice_redactor:register"

[project.entry-points."mgf.common.observability.log_handlers"]
audit_kafka = "vmanager_audit:register_kafka_handler"

[project.entry-points."mgf.common.vault.backends"]
hashicorp = "vmanager_vault_hashicorp:register"
```

Each entry's value is a `module:callable` reference; the
callable takes no arguments and returns nothing — it just calls
the existing `register_*` API the way a manual import would. The
group's namespace mirrors the registry name so the contract is
discoverable by reading the `[project.entry-points]` block of any
plug-in package without consulting upstream docs.

`bootstrap()` walks every group via
`importlib.metadata.entry_points()` after wiring identity but
before wiring the registries that consume registered plugins.
Plug-ins that fail to load (`ImportError`, `register()` raises)
emit a single `MgfPluginLoadError` warning and proceed — the
same "best-effort observer" shape (rule **EH-10**) every other
observer in the library follows, so a broken third-party plugin
doesn't break the host's bootstrap.

The existing explicit-call API stays — entry-point discovery is
strictly additive. Consumers that prefer explicit wiring (the
"closed-box" mode) can pass `bootstrap(plugins="off")` (or set
`MgfSettings(plugins="off")`) to skip the entry-point walk
entirely.

The pattern matches what `setuptools` / `pytest` / `flake8` /
`pluggy`-based frameworks have used for a decade; consumers
already understand it and shipping tooling (uv, build, pip)
already supports declaring entry points. Cost on the upstream
side: ~50 lines of entry-point walking + tests.

---

#### `PAPER-11` — Consumers translating `VersionedFileStore[T]` errors string-match on private message markers; typed `error_kind` (or subclasses) would close the contract gap

| Field | Value |
|---|---|
| Status | ✅ SHIPPED |
| Severity | 🟡 High |
| Submitter | VManager (Phase MGF-3 catch-up walk, 2026-05-02) |
| Submitted | 2026-05-02 |
| Decision | accept (Approach A — `AppConfigErrorKind` enum field) |
| Decided on | 2026-05-02 |
| Scheduled for | 0.15.0 |
| Shipped in | `mgf.common.exceptions.AppConfigErrorKind` (StrEnum) + `AppConfigError.kind: AppConfigErrorKind \| None = None` (v0.15.0). Every load-path raise in `mgf.common.versioned._store` sets `kind` (FILE_NOT_FOUND, IO_READ_FAILED, JSON_PARSE_FAILURE, YAML_PARSE_FAILURE, NON_DICT_ROOT, MISSING_VERSION, UNSUPPORTED_VERSION, MIGRATE_FAILED, SCHEMA_VALIDATION). Save-path raises don't yet opt in — flag a follow-up if a consumer hits pain there. |

**Retrospective.** Implemented Approach A verbatim. Added two
kinds VManager's enum didn't list — `FILE_NOT_FOUND` and
`IO_READ_FAILED` — to cover ALL the load-path raise sites; if
consumers translate VFS errors and a raise site has `kind=None`,
they're forced back to message inspection for that site, which
defeats the whole purpose. Dropped vmanager's `NO_MIGRATE` (it
overlapped 1:1 with `UNSUPPORTED_VERSION` in the actual code).
Backward-compatible: every existing `except AppConfigError:`
clause keeps catching unchanged; consumers that don't
read `e.kind` see no change.

**Concern.** `VersionedFileStore[T].load()` raises a single
exception class (`AppConfigError`) for at least five distinct
failure modes:

1. JSON / YAML parse failure (the on-disk bytes are not valid).
2. Top-level non-dict (`raw` is a string / list / number).
3. Missing typed `version` field
   (when `tolerate_missing_version=False`).
4. On-disk version differs from `current_version` and no
   `migrate` callback is registered.
5. Pydantic schema-validation failure (missing required field,
   wrong type, value outside constraint).

Each case carries a distinguishable error **message** (literal
strings like `"is malformed (json)"`,
`"must be a mapping at the root"`,
`"is missing a typed 'version' field"`,
`"has version=N"`, `"failed schema validation"`). The exception
**class** is the same in every case.

Consumers that want to translate these to project-flavoured
wording — because their CLI or their tests pin specific error
text the consumer's API has historically promised — currently
have to **string-match on the private message format**. The
markers live in `mgf/common/versioned/_store.py` and have no
documented stability contract.

**Why it matters.** During Phase MGF-3 of VManager's catch-up
walk, three migrations needed this translation pattern:

- `src/vmanager/forensics/capture/filesystem.py::load_baseline`
- `src/vmanager/forensics/manifest.py::load_manifest`
- `src/vmanager/security/isolation/baseline.py::load_baseline`

Each ended up writing a discriminator like:

```python
except AppConfigError as e:
    raw = str(e).lower()
    if "is malformed (json)" in raw:
        msg = f"... is unreadable: {e}"
    elif "must be a mapping at the root" in raw:
        msg = f"... is not a JSON object"
    elif "has version=" in raw or "is missing a typed" in raw:
        msg = f"... has unsupported schema_version"
    else:
        # catch-all for schema-validation failures
        msg = f"... is malformed: {e}"
    raise OperationError(msg) from e
```

Three modules × ~four branches each = twelve string-match call
sites that silently break the moment upstream rewords any of
the markers (e.g., "is malformed" → "could not parse"; "has
version=" → "version mismatch on disk:"). The break is
**invisible** in CI — the consumer's tests pass with the legacy
wording in the catch-all branch, the legacy `match=` regexes in
the consumer's tests still match the catch-all message, and
nothing fires until a user hits the specific error path in
production.

Across N consumers each doing K translations, the multiplier is
N × K silently-fragile call sites. The same scaling argument
that drove PAPER-04 (VFS itself was the "every consumer ships
the same shape" pattern) applies here: **every consumer doing
error translation duplicates this discrimination, and every one
of them is fragile on the same upstream reword**.

**Suggested shape.** Either of two approaches; both surface a
typed contract consumers can switch on without string matching.

**Approach A — `error_kind` field on `AppConfigError`.**

```python
from enum import StrEnum

class AppConfigErrorKind(StrEnum):
    JSON_PARSE_FAILURE = "json_parse_failure"
    YAML_PARSE_FAILURE = "yaml_parse_failure"
    NON_DICT_ROOT       = "non_dict_root"
    MISSING_VERSION     = "missing_version"
    UNSUPPORTED_VERSION = "unsupported_version"
    SCHEMA_VALIDATION   = "schema_validation"
    MIGRATE_FAILED      = "migrate_failed"
    NO_MIGRATE          = "no_migrate"

class AppConfigError(AppError):
    kind: AppConfigErrorKind | None = None  # None = legacy/general
    # ... existing fields ...
```

Each `raise` site in `_store.py` sets `exc.kind = AppConfigErrorKind.X`.
Consumers translate with:

```python
except AppConfigError as e:
    match e.kind:
        case AppConfigErrorKind.JSON_PARSE_FAILURE | AppConfigErrorKind.YAML_PARSE_FAILURE:
            raise OperationError("... is unreadable") from e
        case AppConfigErrorKind.UNSUPPORTED_VERSION | AppConfigErrorKind.MISSING_VERSION:
            raise OperationError("... has unsupported schema_version") from e
        case _:
            raise OperationError(f"... is malformed: {e}") from e
```

**Approach B — typed subclasses.**

```python
class MalformedDocumentError(AppConfigError):
    """JSON / YAML parse failure."""
class NonDictRootError(AppConfigError):
    """Top-level value is not a mapping."""
class MissingVersionFieldError(AppConfigError):
    """version field absent or wrong type."""
class UnsupportedVersionError(AppConfigError):
    """on-disk version != current_version and no migrate."""
class StoreSchemaValidationError(AppConfigError):
    """Pydantic field validation failed."""
```

Consumers translate with:

```python
except MalformedDocumentError:
    raise OperationError("... is unreadable") from e
except UnsupportedVersionError:
    raise OperationError("... has unsupported schema_version") from e
except AppConfigError as e:
    raise OperationError(f"... is malformed: {e}") from e
```

Both approaches preserve the existing `AppConfigError` parent
type — every existing `except AppConfigError:` clause keeps
catching the new typed children. Backward-compatible.

**Recommend Approach A** (the enum field). Smaller diff
upstream, no new public class names, easier for consumers to
exhaustively check via Python 3.10+ structural pattern matching,
and the enum doubles as a stable string contract for any
non-Python consumer that ends up reading the JSON crash report
output.

The same argument generalises to other rich-error sites in
`mgf-common` (e.g., `EH-02` translation seams, `BootstrapError`
which today carries `failed_step: str`): typed kinds beat
prose, especially when consumers translate. PAPER-11 is just
the first instance the reference consumer hit hard enough to
file.

---

#### `PAPER-12` — `PUBLIC_API.md` and recipes drift ahead of the published wheel; consumers reading from a local checkout cannot tell what's actually shipped

| Field | Value |
|---|---|
| Status | ✅ SHIPPED |
| Severity | 🟢 Nice-to-have |
| Submitter | VManager (Phase MGF-3 catch-up walk, 2026-05-02) |
| Submitted | 2026-05-02 |
| Decision | accept (banner-form fix; CI-from-tag deferred) |
| Decided on | 2026-05-02 |
| Scheduled for | docs-only (no version bump) |
| Shipped in | banner at the top of `PUBLIC_API.md` directing readers to `pip show mgf-common` + `PUBLIC_API.json`'s `__version__` field for the actually-installed surface; the heavier "publish docs from release tags" alternative is deferred until CI lands (per the strategic-review P2 priority) |

**Concern.** During Phase MGF-3 of VManager's catch-up the
consumer read `mgf_common/PUBLIC_API.md` and
`docs/public/recipes/versioned.md` from the maintainer's master
checkout, which described `VersionedFileStore[T]` as available
in 0.12.0. The published wheel did not yet ship the module —
master was at 0.13.0-dev. Two rounds of confusion followed
(working assumption: VFS is shipped; reality: not yet). The
same desync recurred for PAPER-07's `default_log_path`.

**Why it matters.** Any consumer who reads the docs from a
local mgf-common checkout (the recommended workflow per
`CLAUDE.md`'s "Round 5 critical facts") will hit the same trap
whenever the maintainer is between a feature merge and a
release. The signal "this is in the docs" reads as "this is
shipped" — which it isn't, until the next `twine upload`. The
same confusion bit the maintainer's own session on 2026-05-02
when responding to the catch-up status — see the local-dist
vs PyPI mismatch resolved in commit `5bef66f`'s lead-up.

**Suggested shape.** A small banner or version stamp at the
top of `PUBLIC_API.md` (and the recipe docs) that says
"documents v...-dev — features marked NEW since the last
release tag are unreleased; consult `pip install
mgf-common==<published>` for the actual surface." Or: tag
releases on master and have CI publish `PUBLIC_API.md` from the
tag, not the tip. Either way, the desync gap closes.

---

#### `PAPER-13` — `mgf-common catch-up` misdetects framework apps as `kind: library` when no `pyproject.toml` exists

| Field | Value |
|---|---|
| Status | ✅ SHIPPED |
| Severity | 🟡 High |
| Submitter | inthewords (Phase 0 discovery, 2026-05-05) |
| Submitted | 2026-05-05 |
| Decision | accept (heuristic ladder per the suggested shape) |
| Decided on | 2026-05-05 |
| Scheduled for | 0.17.2 |
| Shipped in | `_detect_kind_no_pyproject` in `src/mgf/common/cli/_catch_up.py` — `manage.py` at the project root → `django`. Wired into `_resolve_project_identity` so `--kind` defaults pick up the heuristic instead of always returning `library`. Pairs with PAPER-17 (the with-pyproject case) for full kind-detection coverage. |

**Retrospective.** Implemented per the suggested shape, conservatively. Only the `manage.py → django` mapping ships in 0.17.2 — that's the consumer evidence the filing brought. Future no-pyproject framework markers (Flask `app.py`, Rails-equivalent layouts) ship when a real consumer files concrete pain. Premature heuristics on speculative project shapes calcify wrong; this one was easy to defend.

**Concern.** `mgf-common catch-up` v0.16.0 reports
`Project: inthewords (kind: library)` on a Django web
application. The detection appears to fall back to `library` when
`pyproject.toml` is absent. The kind drives downstream scaffolding
suggestions (`.claude/settings.json` defaults, the BEGIN/END
auto-block content, what counts as the "right" four-gate config),
so a wrong kind cascades into wrong recommendations.

**Why it matters.** As the second independent consumer (and the
first web/framework consumer) we're the first to surface this —
VManager has a `pyproject.toml` so it never hits the fall-back.
Every future Django, FastAPI, Flask, or Rails-equivalent
consumer that relies on `requirements/*.txt` plus
`manage.py` / `wsgi.py` / `asgi.py` will be misclassified.
Once the misclassification ships in their `.claude/settings.json`
defaults, fixing it is a manual editing exercise; doing it
right at first detection is one extra heuristic upstream.

**Concrete evidence.** Run on `inthewords` working tree at
`/home/bassam/PycharmProjects/inthewords` (commit `b9fecfa`,
2026-05-05):

```
$ mgf-common catch-up
mgf-common catch-up — auditing /home/bassam/PycharmProjects/inthewords
  Project:        inthewords (kind: library)
  mgf-common:     0.16.0
```

Markers that should have flipped detection to a framework kind:

- `manage.py` at repo root (Django CLI entry point).
- `apps/` flat package tree with sub-packages following Django's
  `AppConfig` pattern (e.g. `apps/core/apps.py::CoreConfig`).
- `config/asgi.py`, `config/wsgi.py`, `config/urls.py`,
  `config/settings/{base,dev,local,prod}.py` — Django's standard
  config layout.
- `requirements/{base,dev,prod}.{txt,lock}` declaring `Django`
  as a runtime dep.
- No `src/` layout, no `setup.py`, no `pyproject.toml` — none of
  the library-shape indicators.

**Suggested shape.** Extend `_detect_project_kind` with a small
heuristic ladder, ordered most-specific first:

```python
def detect_kind(root: Path) -> str:
    # Framework consumers.
    if (root / "manage.py").exists():
        if (root / "config" / "asgi.py").exists() or \
           (root / "config" / "wsgi.py").exists():
            return "django-app"
        return "django-cli"  # rare but possible
    if (root / "main.py").exists() and _imports_fastapi(root):
        return "fastapi-app"
    if (root / "Gemfile").exists() and (root / "config.ru").exists():
        return "rails-app"

    # Library / CLI / desktop default ladder (existing logic).
    if (root / "pyproject.toml").exists():
        return "library"  # current default
    return "unknown"
```

The existing `kind: library` default stays for legitimate library
projects without `pyproject.toml` (rare but possible). A new
`kind: unknown` fallback would be honest about "I don't know
what shape this is" — better than guessing wrong silently.

The `.claude/settings.json` and CLAUDE.md auto-block templates
become per-kind: `templates/django-app/.claude.settings.json`,
`templates/library/.claude.settings.json`, etc. catch-up picks
the right template at scaffolding time.

This is also the precondition for HTTP-01 / CONFIG-01 / LOG-01
below — once the audit knows it's looking at a Django app, it
can apply framework-aware rules instead of CLI/desktop-shaped
ones.

---

#### `PAPER-14` — `mgf-common catch-up` v0.1-pattern audit produces false positives by grepping `.md` files

| Field | Value |
|---|---|
| Status | ✅ SHIPPED |
| Severity | 🟡 High |
| Submitter | inthewords (Phase 0 discovery, 2026-05-05) |
| Submitted | 2026-05-05 |
| Decision | accept (real bug — different root cause than the filing identified) |
| Decided on | 2026-05-05 |
| Scheduled for | 0.17.1 |
| Shipped in | `_count_matches` in `src/mgf/common/cli/_catch_up.py` now skips `_AUDIT_SKIP_DIRS` (`tests/`, `.venv/`, `__pycache__/`, etc.) — same skip-dirs as `_check_closed_box_leaks`. Closes the false-positive class. |

**Retrospective.** The filing's hypothesis ("audit greps `.md`
files") was incorrect — `_count_matches` already filtered to
`*.py` via `rglob`. The actual bug was that `_check_v01_patterns`
falls back to the project root when `<target>/src/` is absent
(typical for Django shape projects), and the project root
includes `.venv/` where mgf-common's OWN deprecated shim
definitions live (e.g., `def setup_logging(...):` in
`mgf/common/observability/logging_setup.py`). Walking into
`.venv` counted those library-internal definitions as
user-source v0.1 call sites. Fix: apply the same
`_AUDIT_SKIP_DIRS` filter that `_check_closed_box_leaks`
already uses (also renamed `_LEAK_SKIP_DIRS` → `_AUDIT_SKIP_DIRS`
since the skip-list serves multiple audits, with a backward-compat
alias kept in place).

**Concern.** The v0.1-pattern audit row reports legacy call
sites by counting raw substring matches without filtering to
`.py` files. It hits Markdown documentation that mentions the
pattern names. On a project with zero actual mgf-common imports
but a tracking doc that names the patterns for documentation
purposes, the audit reports dozens of "legacy v0.1 call sites
found" — every one of them a false positive.

**Why it matters.** Adoption-time trust in catch-up's output is
load-bearing. A new consumer sees "18 `setup_logging(` call
sites found" and either (a) spends time hunting for them only
to discover they're all in their own `MGF_COMMON_ADOPTION.md`
tracking doc, or (b) starts a migration that doesn't actually
need to happen. Both erode confidence in the rest of the
audit's findings.

The brief expressly recommends consumers write a tracking doc
that names the v0.1 patterns ("Migrate by hand to `with
mgf.common.bootstrap(...) as ctx:`"). The very act of following
that recommendation triggers the false-positive.

**Concrete evidence.** On the `inthewords` working tree
(2026-05-05), `grep -rn` for the audited patterns across the
entire repo (excluding `.venv`) returns hits only from one
file: `docs/MGF_COMMON_ADOPTION.md` — the tracking doc opened
to plan the adoption. Counts of each pattern in that doc:

| Pattern | Hits in doc | catch-up reported |
|---|---|---|
| `mgf.common.configure(` | 1 | 4 |
| `make_settings_config(` | 0 | 5 |
| `setup_logging(` | 1 | 18 |
| `setup_otel(` | 0 | 4 |
| `install_excepthook(` | 1 | 4 |
| `install_threading_excepthook(` | 0 | 2 |

Project source has zero hits for any pattern (`grep -rn ...
--include="*.py"` returns no rows outside `.venv`). The `.venv`
itself (where `mgf-common 0.16.0` is installed) also has zero
hits for the patterns in the bundled module source.

The reported counts (4 / 5 / 18 / 4 / 4 / 2 = 37 total) appear
to over-count even relative to the doc's text — possibly a
secondary count bug (each instance of `(` counted, or each
comma-separated example, etc.) but the larger point stands:
a single documentation file produces 37 spurious "call site"
findings.

**Suggested shape.**

**Approach A — file-extension allow-list.** Restrict the audit
walk to `.py` files only. Markdown, RST, plain text, and other
documentation formats stay untouched. Smallest diff; matches the
conceptual scope ("legacy CALL SITES").

**Approach B — directory exclusion list.** Add a default-skip
list (`docs/`, `_build/`, `site/`, `*.md`, `*.rst`, `*.txt`)
plus a project-level `.mgf-commonignore` (gitignore-style) for
project-specific exclusions.

**Approach C — both.** Default to `.py`-only; make the
extension set configurable for projects that legitimately have
v0.1 patterns in non-Python files (rare but possible — a `.pyi`
stub, a Cython `.pyx` file, etc.).

**Recommend Approach A.** Smallest diff, matches the audit's
actual purpose. Approach C is over-engineering until a real
non-`.py` consumer file shows up. Approach B is reasonable but
the extension filter is more precise than a directory list (a
directory list misses `*.md` files at the repo root, like a
`README.md` that happens to mention an mgf-common API).

The `closed-box-leak` audit (PAPER-08, shipped) likely already
filters to `.py` — confirm and apply the same scope to the
v0.1-pattern row.

---

#### `PAPER-15` — `mgf-common catch-up` doesn't recognize pip-tools `requirements/*.{txt,lock}` layouts

| Field | Value |
|---|---|
| Status | 🟡 PARTIAL — better warning + workaround surfaced; full pin/four-gate audit deferred |
| Severity | 🟡 High |
| Submitter | inthewords (Phase 0 discovery, 2026-05-05) |
| Submitted | 2026-05-05 |
| Decision | accept partial (improved warning); defer full manifest support |
| Decided on | 2026-05-05 |
| Scheduled for | 0.17.2 (partial); follow-up PAPER for full support |
| Shipped in | `_has_pip_tools_layout` in `src/mgf/common/cli/_catch_up.py` detects `requirements/*.{txt,lock}` layouts; `_check_pyproject_pin` uses it to surface a tailored info-level message ("pin audit + four-gate audit currently require a pyproject.toml; full pip-tools-manifest support deferred to a follow-up; minimal-pyproject.toml workaround documented inline"). |

**Retrospective.** The full ask — read the pin from `requirements/base.txt`, read four-gate config from standalone `pytest.ini` / `ruff.toml` / `mypy.ini` — is a substantial code addition (~150-200 LOC + tests + a manifest-abstraction layer). Deferring until either (a) inthewords reports the partial fix isn't enough, or (b) a second pip-tools-layout consumer surfaces. The partial fix turns the audit's behaviour from "silently invisible" to "explicitly tells you what to do" — which closes the adoption-time confusion the filing surfaced. Full support tracked as a follow-up papercut.

**Concern.** `mgf-common catch-up` reports `no pyproject.toml
found; standards adoption usually needs one.` and `no
pyproject.toml; cannot check gate config.` on projects that
declare dependencies via the pip-tools layout —
`requirements/{base,dev,prod}.{txt,lock}`. This pattern is
common in Django shops, in projects that pre-date PEP 621
adoption, and in any project where the runtime/dev/prod split
is meaningful enough to warrant separate manifest files.

The catch-up tool can audit neither the `mgf-common` pin nor
the four-gate config when the manifest is `requirements/*.txt`
— two of the six audit rows go un-checkable.

**Why it matters.** The pip-tools pattern is well-established
(`pip-compile` ships in `pip-tools` 7.x; the lock file format is
deterministic and widely understood). A project using it is not
mis-shaped — it just declares its deps differently than a
PEP 621 `pyproject.toml`. Refusing to audit such projects, or
silently skipping the four-gate row, locks them out of catch-up's
value before any standards adoption can begin.

The natural workaround ("create a minimal `pyproject.toml`")
is fine but undocumented and undiscoverable. A consumer who
runs catch-up, sees "no pyproject.toml found", and concludes
"the tool doesn't support my project shape" walks away.

**Concrete evidence.** The `inthewords` working tree
(2026-05-05) declares deps as:

```
requirements/
├── base.txt        (44 lines, runtime deps with semver-major pins)
├── base.lock       (pip-compile output, fully resolved)
├── dev.txt         (24 lines, -r base.txt + dev tools)
├── dev.lock        (pip-compile output)
├── prod.txt        (-r base.txt + prod-only deps)
└── prod.lock       (pip-compile output)
```

Driven by `OPS-01: pip-compile to generate reproducible .lock
files` (recorded in `apps/core/management/commands/seed_data.py`
header and `requirements/dev.txt:8`).

CI installs from the lockfile (`.gitea/workflows/ci.yml:94`):

```yaml
- name: Install dependencies (from lockfile)
  run: |
    pip install --upgrade pip
    pip install -r requirements/dev.lock
```

No `pyproject.toml`, no `setup.py`, no `setup.cfg`. The project
is L1-ready in spirit but invisible to catch-up's pyproject-
oriented audit rows.

**Suggested shape.** Extend the manifest detection to a small
ladder, ordered by precedence:

```python
def find_dep_manifest(root: Path) -> DepManifest:
    if (root / "pyproject.toml").exists():
        return PyprojectManifest(...)
    req_dir = root / "requirements"
    if req_dir.is_dir():
        files = list(req_dir.glob("*.txt"))
        if files:
            return RequirementsManifest(req_dir, files)
    if (root / "requirements.txt").exists():  # flat layout
        return RequirementsManifest(root, [root / "requirements.txt"])
    return None  # no recognized manifest; skip pin/gate audit rows
```

The pin audit reads the `mgf-common>=0.X,<0.Y` line from
`requirements/base.txt` (or wherever it lives) just like it
reads the `[project] dependencies = [...]` block from
pyproject. Four-gate audit reads `pytest.ini`, `ruff.toml`,
`mypy.ini`, and a coverage config from the standalone files
that pip-tools projects naturally use (none of those four are
pyproject-only).

If a feature truly requires PEP 621 metadata (e.g., entry-points
discovery for PAPER-10's plugin walk), document it as such; the
generic four-gate audit doesn't have that requirement.

The "create a minimal pyproject.toml" workaround can be
documented in the catch-up output itself when no manifest is
recognized — "no recognized manifest found. mgf-common supports:
pyproject.toml, requirements/*.txt, requirements.txt at root.
Create one of these to enable pin and four-gate auditing."

---

#### `HTTP-01` — `EH-*` exception category taxonomy doesn't fit HTTP-domain consumers

| Field | Value |
|---|---|
| Status | ✅ SHIPPED |
| Severity | 🟡 High |
| Submitter | inthewords (Phase 0 discovery, 2026-05-05) |
| Submitted | 2026-05-05 |
| Decision | accept (Approach A — `HttpError` as eighth category, with concrete leaves) |
| Decided on | 2026-05-06 |
| Scheduled for | 0.18.0 |
| Shipped in | `mgf.common.exceptions.HttpError` + `HttpClientError` (4xx) + `HttpServerError` (5xx) + 9 concrete leaves (`HttpBadRequest` 400, `HttpUnauthorized` 401, `HttpForbidden` 403, `HttpNotFound` 404, `HttpConflict` 409, `HttpGone` 410, `HttpUnprocessable` 422, `HttpInternalServerError` 500, `HttpServiceUnavailable` 503) — 12 new public names. Each carries `http_status: int` as a class attribute for framework integration. All names ship at stability tier `experimental` per AP-11. Migration guide in [`docs/cutover/v0.18.0.md`](docs/cutover/v0.18.0.md#http-01--httperror-category). |

**Retrospective.** Implemented Approach A from the filing,
verbatim. The category-catch property holds at three levels —
`except HttpError:` / `except HttpClientError:` (4xx) /
`except HttpServerError:` (5xx) — which the test suite pins.
Consumer subclassing pattern (`RoomNotFoundError(HttpNotFound)`
inherits `http_status=404` automatically) is the headline
ergonomic; framework integration (Django middleware, FastAPI
exception handler) reads `exc.http_status` for response mapping.
Distinct from `ResourceNotFoundError` (provider-domain) by
deliberate non-inheritance — both subclass `AppError`, neither
subclasses the other. Test count grew from 16 to 29 in
`tests/public_surface/test_exceptions_as_user.py`.

**Concern.** `docs/standards/ERROR_HANDLING.md` §2.1 prescribes
seven category bases for typed exceptions: `ConfigError`,
`ProviderError`, `OperationError`, `GuestError`,
`SchedulerError`, `VaultError`, `EnvironmentError`. The category
layer is load-bearing for the standard's "shallow but typed by
category" design — it's how consumers can `catch X` for a
whole class of failures without naming every concrete subclass.

For systems-management domain (VM lifecycle, scheduler, vault,
guest agents) this taxonomy is well-shaped — VManager's source
maps cleanly onto it. For HTTP-domain consumers (web apps,
APIs, services), it doesn't map at all. Web apps' natural
exception taxonomy is HTTP-status-coded:

| Web exception | HTTP status | mgf-common category |
|---|---|---|
| `AuthenticationRequiredError` | 401 | none of the seven |
| `PermissionDeniedError` | 403 | none of the seven |
| `NotFoundError` | 404 | not `ResourceNotFoundError` (that's provider-domain) |
| `ConflictError` | 409 | none of the seven |
| `RoomArchivedError` | 410 (Gone) | none of the seven |
| `ValidationError` | 400 | not `SchemaValidationError` (that's config-domain) |
| `ServiceUnavailableError` | 503 | none of the seven |

A web consumer who follows EH-* literally must either (a) map
every exception to the closest mgf category by stretch (likely
`OperationError` for everything, defeating the category-catch
property), (b) skip the category layer and subclass `AppError`
directly (defeating EH-*'s prescribed shape), or (c) propose a
new category — which is what this entry does.

**Why it matters.** Every web/API consumer hits this on day
one. The reference consumer's standards docs implicitly
encode "consumer = systems-management library", which the
strategic-review priority 1 (second consumer) is supposed to
correct. We're the first web consumer; if HTTP-* isn't a
recognised category now, every future web consumer faces the
same "skip the layer or stretch the names" choice.

The `inthewords` central interfaces program (Phase 8D, lint-
enforced via `apps/core/tests/test_central_interfaces.py`) has
9 concrete exception classes, all subclassing a project
`AppError` base. Each carries an `http_status` class attribute
(`apps/core/exceptions.py:265-323`). They form a perfectly
valid hierarchy on their own; the question is how to compose
them with mgf-common's category layer when we adopt.

**Concrete evidence.** Project hierarchy at
`apps/core/exceptions.py` (`inthewords` repo, commit `b9fecfa`):

```
AppError                             (line 126; http_status=400 default)
├── NotFoundError                    (line 265; http_status=404)
│   ├── RoomNotFoundError            (line 295; http_status=404)
│   ├── RoomArchivedError            (line 301; http_status=410)
│   └── MessageNotFoundError         (line 313; http_status=404)
├── PermissionDeniedError            (line 271; http_status=403)
│   ├── AuthenticationRequiredError  (line 277; http_status=401)
│   └── MembershipRequiredError      (line 307; http_status=403)
├── ValidationError                  (line 283; http_status=400)
├── ConflictError                    (line 289; http_status=409)
└── ServiceUnavailableError          (line 319; http_status=503)
```

Project HTTP integration (`apps/core/exception_handlers.py`)
maps `exc.http_status` to the response status code via DRF and
Django middleware. This is the canonical web-app shape — it's
what every Django, FastAPI, and Starlette consumer will end up
with.

**Suggested shape.**

**Approach A — Add `HttpError` as an eighth category.**

```python
# In mgf.common.exceptions:
class HttpError(AppError):
    """Base for web/API exceptions carrying an HTTP status code."""
    http_status: int = 500   # subclasses override

class HttpClientError(HttpError):
    """4xx — request was bad."""

class HttpServerError(HttpError):
    """5xx — server failed to fulfill a valid request."""

# Optionally, common 4xx/5xx leaves:
class HttpUnauthorized(HttpClientError):     http_status = 401
class HttpForbidden(HttpClientError):        http_status = 403
class HttpNotFound(HttpClientError):         http_status = 404
class HttpConflict(HttpClientError):         http_status = 409
class HttpGone(HttpClientError):             http_status = 410
class HttpUnprocessable(HttpClientError):    http_status = 422
class HttpServiceUnavailable(HttpServerError): http_status = 503
```

Web-app consumers subclass these for project-specific cases
(`RoomNotFoundError(HttpNotFound)`, etc.) and get the
category-catch property: `except HttpClientError:` catches
every 4xx, `except HttpServerError:` catches every 5xx, `except
HttpError:` catches both.

**Approach B — Bless "skip categories, subclass `AppError`
directly" for web consumers.**

The standards explicitly state that web/HTTP-domain consumers
MAY subclass `AppError` directly without going through a
category layer, citing HTTP-status-driven exception taxonomies
as a recognised exception. The category-catch property is
documented as N/A for web consumers; HTTP middleware fills the
"catch by category" role via status-code routing instead.

**Recommend Approach A.** Smaller standards-doc change, larger
upstream-code change. The HTTP categories generalise to every
future web consumer (FastAPI, Starlette, Flask, aiohttp); the
in-tree leaves can be adopted or extended by each consumer.
Backward-compatible: existing consumers don't subclass
`HttpError` and don't see any change.

Approach B is the lower-cost answer if HTTP-domain consumers
turn out to be a minority of the consumer set, but it forces
every web consumer to re-litigate the same "should we use
mgf-common's categories?" decision. Approach A makes the
answer "yes — these ones" and ends the conversation.

A standards-doc section on "EH-* for web consumers" would help
either way (a single Markdown page in
`docs/standards/web/ERROR_HANDLING.md` or a sub-section in the
existing doc).

---

#### `CONFIG-01` — `CF-*` config standard is structurally incompatible with framework-native settings (Django, Rails-equivalent)

| Field | Value |
|---|---|
| Status | ✅ SHIPPED |
| Severity | 🟡 High |
| Submitter | inthewords (Phase 0 discovery, 2026-05-05) |
| Submitted | 2026-05-05 |
| Decision | accept (Approach A — framework-native carve-out section + per-rule applicability) |
| Decided on | 2026-05-06 |
| Scheduled for | 0.18.0 |
| Shipped in | `docs/standards/CONFIGURATION.md` gains §0a "Framework-native config (Django, Rails-equivalent)" with a per-rule applicability table (CF-01/04/08/09 are N/A; CF-02/05/10/12 still apply; CF-03/06/07/11 are N/A under framework-native) + an explicit "carve-out isn't an exemption from discipline" sub-section + the `inthewords` worked example. The Rules box itself gains an `Applies to` column (`all` vs `consumer-owned-config`) so the scope is machine-readable. Migration guide in [`docs/cutover/v0.18.0.md`](docs/cutover/v0.18.0.md#config-01--framework-native-config-carve-out). |

**Retrospective.** Implemented Approach A from the filing.
Approach B (move framework-native to a separate
`FRAMEWORK_CONFIG.md`) deferred — the carve-out shape kept the
canonical doc as the single source of truth. The new "carve-out
isn't an exemption from discipline" sub-section is a
maintainer-side addition emphasising that lint enforcement
(env-var-reads-only-in-settings.py-style tests) and CF-05
(secrets in vault) MUST still hold under framework-native. The
worked example uses `inthewords`'s actual config layout; future
Django consumers can copy verbatim. Rails / Flask carve-outs
land via `FRAMEWORK-NN` papers when those consumers file
specific friction.

**Concern.** Several CF-* MUSTs in
`docs/standards/CONFIGURATION.md` directly contradict the
canonical Django (and broadly framework-native) settings
pattern. A Django consumer cannot simultaneously be CF-*
compliant and use `from django.conf import settings`.

Specific contradictions:

| Rule | Mandate | Django pattern |
|---|---|---|
| **CF-01** | "No module reads the config file or env vars directly." | `config/settings/*.py` reads env vars via `python-decouple` / `os.environ` directly. The Django settings module IS the place where env vars are consumed. |
| **CF-08** | "Module-global singletons are forbidden. The Settings object MUST be loaded once at the entry point and passed down by constructor injection." | `from django.conf import settings; settings.X` is a module-global singleton lookup. It IS the canonical Django access pattern. Removing it would mean rewriting tens of thousands of call sites across every Django project on the planet. |
| **CF-09** | "A `--config <path>` CLI flag MUST always exist." | Django uses `DJANGO_SETTINGS_MODULE` env var to point at a Python module, not a config file path. ASGI/WSGI processes don't take CLI args at all. |
| **CF-04** | "Writes to the on-disk config file MUST be atomic (temp file + rename). Mode MUST be `0o600`." | Django settings are version-controlled Python code, deployed via Ansible/Terraform/Docker. There is no runtime write of "the config file." |

CF-02 (precedence: CLI → env → .env → config file → typed
defaults), CF-05 (secrets in vault not config), CF-06 (unknown
keys raise), CF-07 (sub-config files same shape), CF-10 (tests
construct Settings explicitly), CF-11 (rename via alias) all
apply or have natural Django equivalents. The mismatch is
specifically with CF-01, CF-04, CF-08, CF-09 — the four MUSTs
that encode the "consumer-owned, on-disk YAML, CLI-loadable"
shape.

**Why it matters.** Adoption requires either (a) declaring
CF-* not applicable for Django settings (which the standards
don't currently bless), (b) abandoning Django's settings
pattern and rewriting the consumer (impractical), or (c)
filing this entry. Without a clear blessed answer, every
Django/framework consumer re-litigates the same decision and
files variants of this same friction.

The strategic-review priority 1 ("second, independent
consumer") is the right context for this — VManager has its
own `AppConfig` Pydantic class that maps to CF-* cleanly
because VManager OWNS its config schema. Django consumers
delegate config ownership to the framework. That's a structural
distinction worth naming.

**Concrete evidence.** `inthewords` config layout:

- `config/settings/base.py` — env-var-driven base settings,
  ~400 lines, reads env via `python-decouple==3.*`
  (`requirements/base.txt:5`).
- `config/settings/{dev,local,prod}.py` — overlays on `base.py`
  via `from .base import *`.
- `manage.py:12` — `os.environ.setdefault('DJANGO_SETTINGS_MODULE',
  'config.settings.dev')`.
- `apps/core/tests/test_central_interfaces.py::TestEnvVarsOnlyInSettings`
  — lint test that BANS `os.environ.get(`, `os.environ[`, and
  `decouple.config(` anywhere in `apps/`. Settings reads happen
  ONLY in `config/settings/`, and access is via `from
  django.conf import settings`. This lint encodes the project's
  Phase 8D centralisation rule (`docs/CLAUDE.md` "Central
  interfaces" table). It directly enforces what CF-08 forbids.

The project test suite has 950 Python tests; converting every
`settings.X` access to constructor-injected DI would touch
hundreds of files and yield no behavioural improvement
(Django's settings are already a typed-by-convention module).

**Suggested shape.**

**Approach A — Add a "framework-native config" carve-out
section to CF-*.** A new sub-section
`docs/standards/CONFIGURATION.md` §X — "Framework-native
config (Django, Rails, …)" — that explicitly exempts CF-01,
CF-04, CF-08, CF-09 for consumers whose framework owns the
config schema. The remaining CF-* MUSTs (precedence ordering,
secrets→vault, schema versioning, unknown-key reject, etc.)
still apply where they make sense.

**Approach B — Scope CF-* to "applications that own their
config schema".** Re-frame the standard as the "consumer-owned
config" standard; framework-native settings are not in scope.
A separate `docs/standards/FRAMEWORK_CONFIG.md` covers the
Django/Rails case (precedence, secrets, env-var conventions,
unknown-key handling). The two coexist.

**Recommend Approach A.** Smaller change; keeps CF-* as the
canonical document; adds framework-native as a recognised
variant. Clear "L0 / L1 / L2" gating per CF rule with
framework-native exemption notes preserves the conformance
ladder.

Either approach must clarify which CF-* rules apply where.
Today the implicit answer is "all of them, somehow" — which
gates real Django consumer adoption.

A worked example for `inthewords` — under either approach —
would close the gap concretely:

```
inthewords adopts CF-* like this:
  CF-01 — N/A (Django-owned). Env-var reads happen ONLY in
          config/settings/*.py; lint-enforced via
          TestEnvVarsOnlyInSettings.
  CF-02 — APPLIES. Order is: env > .env > settings.py defaults.
  CF-03 — N/A (Django settings have no schema_version field;
          settings ARE Python code).
  CF-04 — N/A (settings are deployed code, not runtime data).
  CF-05 — APPLIES. Secrets in Ansible Vault (prod) / .env (dev),
          never in settings.py.
  CF-06 — N/A (Django attribute access on missing key already
          raises AttributeError).
  CF-07 — Project chooses; inthewords keeps tools/lib/test_config.py
          on the typed-config pattern (CF-* compliant) for the
          test harness.
  CF-08 — N/A (Django mandates module-global access).
  CF-09 — N/A (DJANGO_SETTINGS_MODULE env var replaces it).
  CF-10 — APPLIES. pytest fixtures construct Settings with
          explicit overrides; env leaks banned by conftest.
  CF-11 — N/A (settings rename is a code refactor, not data
          migration).
  CF-12 — APPLIES where relevant (e.g., CACHE_BACKEND alternatives).
```

---

#### `LOG-01` — `LG-06` rotating-file sink mandate doesn't fit cloud-native stdout-only logging

| Field | Value |
|---|---|
| Status | ✅ SHIPPED |
| Severity | 🟡 High |
| Submitter | inthewords (Phase 0 discovery, 2026-05-05) |
| Submitted | 2026-05-05 |
| Decision | accept (parameterised LG-06 + new recipe) |
| Decided on | 2026-05-06 |
| Scheduled for | 0.18.0 |
| Shipped in | `docs/standards/LOGGING.md` LG-06 revised to parameterise on deployment topology — rotating local file (desktop/single-server, no platform aggregator) OR process stdout/stderr (cloud-native/container; runtime captures + forwards) OR remote sink (OTLP/syslog/HTTP, opt-in). "Console only, no aggregation" is non-compliant in production regardless of branch. New recipe at [`docs/public/recipes/cloud-native-logging.md`](docs/public/recipes/cloud-native-logging.md) covers stdout-only `MgfSettings`, JSON formatter choice, structured-log invariants, and the deployment-time check. Migration guide in [`docs/cutover/v0.18.0.md`](docs/cutover/v0.18.0.md#log-01--cloud-native-logging-recognised). |

**Retrospective.** Implemented per the suggested shape. LG-06's
new wording explicitly recognises three first-class topologies
(file, stdout-aggregator, remote-sink) instead of the original
file-mandate + opt-in-others framing. The desktop / single-server
branch keeps the rotating-file prescription verbatim (no
regression for VManager-shape consumers). The cloud-native
branch is what `inthewords` and `PlasmaMapper` ship; the
recipe walks `inthewords`'s actual deployment topology
(Daphne → systemd journal → Promtail → Grafana Cloud) as the
worked example. Crash-report shipping flagged inline as the
remaining ephemeral-filesystem concern; recipe points at the
existing OTLP/HTTP sinks for that.

**Concern.** `LG-06` mandates "A rotating file sink MUST be
configured by default (under `<state>/logs/`). Console MUST
also receive logs. Remote sinks (OTLP, syslog, HTTP) MUST be
opt-in via env / config." This encodes the desktop-/server-app
shape where the operator runs the binary and tails a file on
disk.

The 12-factor / cloud-native pattern does the opposite:
**logs are an event stream written to stdout/stderr, the
runtime/platform handles aggregation**. Container orchestrators
(Kubernetes, Docker Compose, Heroku, Cloud Run, Fly.io) capture
stdout/stderr and forward to the platform's log aggregator.
Writing to a rotating file inside the container is an
anti-pattern — the file rotates against an ephemeral filesystem
that vanishes when the container restarts.

**Why it matters.** Every cloud-native consumer hits this on
day one. The honest answer for a Django app deployed via
container is "stdout goes to Grafana Cloud / Datadog / CloudWatch
via the platform; no file sink is needed or wanted." The LG-06
mandate either forces the consumer to configure a rotating file
sink they don't need (and that points at an ephemeral mount),
or to declare "we don't follow LG-06" — which the standards
don't currently bless as a recognised pattern.

This is parallel to CONFIG-01: LG-* encodes the desktop/server
shape; cloud-native is a recognised industry pattern that
deserves explicit treatment.

**Concrete evidence.** `inthewords` is deployed on Infomaniak
Public Cloud (3 VMs behind a load balancer). The deploy is
documented in `deploy/docs/first-production-cut.md` (Phase 8A
runbook) and `docs/DESIGN.md`'s "Hosting" section.

The production logging setup (per project memory and
`config/settings/prod.py`):

- Application processes (Django ASGI via Daphne, Celery workers)
  log to stdout in JSON format via `apps.core.logging.JsonFormatter`.
- The Infomaniak VM's systemd journal captures stdout.
- Grafana Cloud reads from journal via Promtail agents.
- Zero file sinks. No `<state>/logs/` directory exists. No
  rotation is configured because there's no file to rotate.

Setting up a rotating file sink in this topology would write to
the VM's local disk (ephemeral on cloud images), bypass the
journal, and force Grafana Cloud to read from two separate
sources. Net regression for compliance with LG-06.

**Suggested shape.** Add a sub-section to
`docs/standards/LOGGING.md` that explicitly recognises the
cloud-native shape. The mandate is restated as a contract
parameterised by deployment topology:

> **LG-06 (revised).** Logs MUST reach an aggregator the
> operator can search. The aggregator MAY be:
> - A rotating local file (desktop / single-server). Required
>   when no platform aggregator is available.
> - The process's stdout/stderr (cloud-native / container).
>   Required when the platform aggregator captures container
>   output (12-factor §XI).
> - A remote sink (OTLP / syslog / HTTP). Required when the
>   service explicitly opts in.
> A "console only, no aggregation" configuration is
> non-compliant in production.

The "rotating file" branch keeps the existing prescription verbatim
for desktop/server consumers. The "stdout" branch acknowledges
the cloud-native pattern as a first-class option. The "remote
sink" branch is unchanged.

A worked example for cloud-native consumers in
`docs/recipes/cloud-native-logging.md` would close the gap
concretely — covering: stdout-only configuration, JSON formatter
choice, structured-log-line invariants the platform aggregator
needs, and the deployment-time check ("the platform IS my
rotating file sink").

LG-07 (GUI log-viewer dialog) is a separate scope question
filed as LOG-02.

---

#### `LOG-02` — `LG-07` GUI log-viewer dialog mandate is N/A for web apps and headless services

| Field | Value |
|---|---|
| Status | ✅ SHIPPED |
| Severity | 🟢 Nice-to-have |
| Submitter | inthewords (Phase 0 discovery, 2026-05-05) |
| Submitted | 2026-05-05 |
| Decision | accept (per-rule applicability + per-kind compliance matrix) |
| Decided on | 2026-05-06 |
| Scheduled for | 0.18.0 |
| Shipped in | `docs/standards/LOGGING.md` Rules box gains an `Applies to` column with values from `{all, gui-app, service, fastapi, django, cli, library}`. LG-07 is now annotated `applies_to: gui-app`. Per-kind compliance matrix added at the top of the Rules box section listing which LG-* rules apply to which detected kinds. `mgf-common catch-up`'s future linter passes can filter by detected kind so non-applicable rules don't surface. Same pattern shipped on CF-* (closes CONFIG-01) and is recommended for the other standards docs as a follow-up. Migration guide in [`docs/cutover/v0.18.0.md`](docs/cutover/v0.18.0.md#log-02--applicability-scope-on-lg--rules). |

**Retrospective.** The smaller of the two LOG-* fixes (severity
🟢) but conceptually the bigger one — establishes the
"applies_to" pattern that ASPIRE-02's "explicit kind-aware
standards" prescription depends on. Same shape now ships on
CF-* (CONFIG-01 carve-out). Rolling out applies-to to EH-*,
DP-*, AP-* is a follow-up paper if a future consumer hits
specific friction; today the LG-* and CF-* coverage is enough
because those were the rules with concrete cross-kind
mismatches.

**Concern.** `LG-07` mandates "GUI apps MUST ship a log-viewer
dialog that reads the rotating file sink so users can view logs
without a terminal." This rule is scoped to GUI apps in its own
text, but the standards-doc structure presents LG-07 next to
LG-01 through LG-14 as if it applied to every consumer.

For web apps, headless services, batch jobs, and CLI tools, LG-07
is N/A — there is no "GUI" in the desktop sense, and log access
happens via the platform aggregator (Grafana, Datadog) or
operator tooling (`journalctl`, `kubectl logs`).

**Why it matters.** Low severity by itself — every consumer can
read "GUI apps MUST" and conclude "this doesn't apply to me."
But adoption tooling (catch-up, future linters) shouldn't ding
non-GUI consumers for not having a log-viewer dialog. The rule's
applicability scope should be machine-readable, not just inferred
from prose.

**Concrete evidence.** `inthewords` is a web application —
Django + Channels + Celery + Postgres + Redis. There is no
desktop client, no Qt/Tk GUI, no mobile shell. The browser-side
HTML+HTMX+Alpine UI does not host a log-viewer; logs live in
Grafana Cloud per LOG-01's evidence.

**Suggested shape.** Two small fixes:

1. Add an explicit "Applicability" sub-row or scope field to
   each LG-* rule. For LG-07: `applies_to: ["gui-app"]`. For
   LG-06 (post-revision per LOG-01): `applies_to: ["all"]`.
   The applicability scope becomes part of the rule's
   machine-readable contract — consumers and audits can both
   filter by their kind.

2. Add a "Per-kind compliance matrix" table at the top of
   `docs/standards/LOGGING.md` listing which rules apply to
   which kinds. Same pattern for the other standards docs as
   their kind-applicability becomes clear.

This unblocks PAPER-13's scaffolding (different kinds get
different audit scope) and complements CONFIG-01's
framework-native carve-out (CF-* rules also need scoped
applicability).

---

#### `PAPER-16` — `mgf-common catch-up --apply` cannot bootstrap BEGIN/END markers into an existing `docs/CLAUDE.md` without clobber risk

| Field | Value |
|---|---|
| Status | ✅ SHIPPED |
| Severity | 🟡 High |
| Submitter | inthewords (Phase 1 scaffolding, 2026-05-05; maintainer pre-confirmed acceptance during relay) |
| Submitted | 2026-05-05 |
| Decision | accept (Approach A — auto-insert with backup) |
| Decided on | 2026-05-05 |
| Scheduled for | 0.17.2 |
| Shipped in | `_apply_bootstrap_claude_md_markers` in `src/mgf/common/cli/_catch_up.py` reads the existing CLAUDE.md, backs it up to `.bak.<timestamp>`, writes a new file with BEGIN/END markers + auto-block at the top + the original content verbatim below the END marker. `_check_claude_md`'s no-markers branch attaches this as the fix-callable, so `catch-up --apply` no longer requires the destructive `init-project --force` workaround. |

**Retrospective.** Implemented Approach A from the filing, verbatim. Subsequent `catch-up --apply` invocations now refresh the auto-block in place via the existing `_apply_refresh_claude_md` (markers are present after bootstrap). The new helper extracts the auto-block-with-markers from a freshly-rendered template via a sibling extractor (`_extract_full_auto_block`) — slightly different from the existing `_extract_auto_block` (which strips markers); both live alongside.

**Concern.** v0.16.0 catch-up's audit row for `docs/CLAUDE.md`
detects the missing BEGIN/END auto-managed markers and stops:

```
⚠️  docs/CLAUDE.md
    exists but lacks BEGIN/END auto-managed markers — either
    pre-v0.11 init-project output or hand-written. catch-up
    cannot refresh the auto-block in place. Either re-run
    `mgf-common init-project --force` (clobbers your
    project-specific lookup section — back it up first), or
    manually add the BEGIN/END markers around the standards
    section.
```

The two documented options are asymmetric:

- `init-project --force` does the right scaffold but clobbers
  every line of project-specific CLAUDE.md content. For any
  project that has invested in CLAUDE.md as a load-bearing
  reference doc (Phase 8D centralisation rules, standing
  instructions, phase-critical-facts, etc.), this destroys
  hours-to-days of work in one command flag.
- Manual marker insertion works but is undocumented as a
  recommended path. The consumer has to know (a) what the
  marker syntax looks like, (b) where in the file the markers
  should go, (c) what content the auto-block will eventually
  contain (otherwise the manual stub may conflict). All three
  are discoverable only by reading another consumer's
  CLAUDE.md or running `init-project` against a scratch
  directory to inspect the template output.

**Why it matters.** Every consumer arriving with an existing
CLAUDE.md (whether hand-written or pre-v0.11 init-project
output) hits this. The clobber risk is one --force flag away
from destruction; the manual workaround is undocumented.
Closing this gap is small upstream code (auto-insert markers
at the top of the file, back up the original) and removes a
real adoption-time hazard.

This is the scaffolding equivalent of REL-02's "version reuse
forbidden" rule for releases: a destructive operation must not
be the documented path when a non-destructive one is one
heuristic away.

**Concrete evidence.** `inthewords/docs/CLAUDE.md` is 1100
lines of project-owned content:

- Standing instructions (~60 lines)
- Documentation directory layout (~40 lines)
- Central interfaces table (~80 lines)
- Logging + error-handling canonical pattern (~100 lines)
- Phase-N critical facts sections (~600 lines)
- Misc invariants, gotchas, fixed-bug notes (~220 lines)

`init-project --force` would replace all 1100 lines with the
template output. The project's manual workaround during Phase
1 (this filing) is to insert BEGIN/END marker stubs at the top
of the file, above the `# InTheWords — Claude Reference`
heading, then let the next `catch-up --apply` populate the
auto-block. The workaround works but is fragile — a new
consumer reading the v0.16.0 audit message lands on the
clobber path because that's the documented option that doesn't
require manual editing.

**Suggested shape.**

**Approach A (recommended) — auto-insert markers with backup.**

`catch-up --apply` detects the marker absence, reads the
existing CLAUDE.md, writes a new file with:

1. The auto-managed BEGIN marker.
2. The current standards auto-block content.
3. The auto-managed END marker.
4. The original CLAUDE.md content, verbatim.

The original file is backed up to `docs/CLAUDE.md.bak.<timestamp>`
before the rewrite, matching the pattern catch-up already uses
for `.claude/settings.json` and other in-tree files. The
consumer ends up with the auto-block at top + their existing
content below the END marker — exactly what `init-project`
would produce on a greenfield run, but non-destructively.

**Approach B — `--init-markers` flag.**

A new explicit flag on catch-up (or a sibling subcommand) that
performs Approach A's transform only when invoked explicitly.
Default behaviour stays the v0.16.0 "stop and warn" shape; the
flag is the consumer's opt-in.

**Recommend Approach A.** The auto-block is a contract artifact
(the standards-doc lookup that AI agents and humans rely on),
not optional flair. A consumer who runs `catch-up --apply`
expects scaffolding to land; stopping with a warn-then-skip on
the most-critical scaffolding artifact is the wrong default.
Backup-and-rewrite is non-destructive in the same shape catch-up
already uses elsewhere — consistent UX.

Approach B is the safer fallback if the maintainer wants to
preserve the v0.16.0 behaviour as default for some reason
(e.g., projects that genuinely don't want CLAUDE.md modified).
The cost is one more thing to discover.

**Decision rationale.** Pending formal triage; maintainer
pre-confirmed acceptance and Approach A choice during relay
on 2026-05-05.

**Retrospective.** (Filled when shipped.)

---

#### `VAULT-01` — Vault registry should accept `config_model=` for typed custom backends

| Field | Value |
|---|---|
| Status | ✅ SHIPPED |
| Severity | 🟡 High |
| Submitter | Maintainer dogfood |
| Submitted | 2026-04-28 |
| Decision | accept (suggested shape verbatim) |
| Decided on | 2026-05-01 |
| Scheduled for | shipped in-tree |
| Shipped in | `register_vault_backend(name, *, config_model=None)` + a `model_validator` on `VaultSettings` that coerces dicts → typed model when registered (in-tree) |

**Concern.** The three built-in vault backends
(`EnvVaultConfig` / `KeyringVaultConfig` /
`EncryptedFileVaultConfig`) ship typed configs via the
discriminated union `BuiltinVaultBackendConfig`. Custom
backends (HashiCorp Vault, AWS Secrets Manager, …) still use
`dict[str, Any]` — the closed-box leak is half-closed.
Consumer code shipping a custom backend writes stringly-typed
config dicts that the IDE / mypy / pydantic won't validate.

**Why it matters.** The whole point of typed configs was to
close the "consumers spelunk source to know what keys each
backend wants" gap. Custom backends still spelunk. Real
third-party stores (HashiCorp / AWS) are exactly where the
typed-config win matters most.

**Suggested shape.** Extend `register_vault_backend` to
optionally accept a `config_model` parameter; the registry
stores the mapping. `VaultSettings.config` then validates
against the registered model when the active `backend` matches:

```python
from mgf.common.vault import register_vault_backend
from mgf.common.config import BaseModel, ConfigDict

class HashicorpVaultConfig(BaseModel):
    model_config = ConfigDict(extra="forbid", frozen=True)
    backend_type: Literal["hashicorp"] = "hashicorp"
    url: str
    token: str
    mount: str = "secret"

@register_vault_backend("hashicorp", config_model=HashicorpVaultConfig)
def make_hashicorp_vault(config: HashicorpVaultConfig) -> VaultProtocol:
    ...
```

`MgfSettings(backend="hashicorp", config=...)` then accepts the
typed config; `BuiltinVaultBackendConfig` becomes a
dynamically-resolved discriminated union from the registry.

**Resolution.** Ships as additive surface:

- `register_vault_backend(name, *, config_model=None)` — the new
  `config_model=` kwarg accepts a `BaseModel` subclass. The
  registry stores `(factory, config_model)` pairs internally.
- `VaultSettings` gets a `model_validator(mode="after")` that
  consults the registry: when the active `backend` has a
  registered `config_model`, an incoming dict is validated and
  replaced with the typed instance; an already-typed instance
  passes through; a wrong typed instance raises with a clear
  error; an unregistered backend leaves the dict untouched
  (factories registered without a model own their own validation).
- Built-in backends (`env` / `keyring` / `file`) keep their
  existing isinstance dispatch + deprecation warning for
  backward compat — the change is purely additive for custom
  backends.

Recipe section in `docs/public/07_vault.md` ("Custom backend with
a typed config — recommended"). Pinned by 8 unit tests + 1
public-surface test.

---

#### `PAPER-17` — `mgf-common catch-up` kind detection mis-classifies FastAPI projects that DO have a `pyproject.toml` as `kind: library`

| Field | Value |
|---|---|
| Status | ✅ SHIPPED |
| Severity | 🟡 High |
| Submitter | PlasmaMapper (Phase 1 catch-up adoption, 2026-05-05) |
| Submitted | 2026-05-05 |
| Decision | accept (Approach A — heuristic from pyproject deps + scripts) |
| Decided on | 2026-05-05 |
| Scheduled for | 0.17.2 |
| Shipped in | `_detect_kind_from_pyproject` in `src/mgf/common/cli/_catch_up.py`: parses `[project] dependencies` + `[project.scripts]` from pyproject.toml. Framework markers in deps → `fastapi` / `django` / `service` (Flask). Non-empty scripts without framework → `cli`. No deps + no scripts → `library`. Uses existing `_KINDS` set from `init-project` (no new template kinds added). Pairs with PAPER-13 (no-pyproject case) for full coverage. |

**Retrospective.** Implemented Approach A from the filing. Approach B (explicit `[tool.mgf]` declaration) deferred — the heuristic covers the canonical shapes today; explicit-override gates can land when a consumer files a real misclassification (e.g., a project that vendors FastAPI internally). Tomllib/tomli not used to stay stdlib-only on 3.11 baseline; line-grep heuristic is forgiving and matches canonical declaration shapes. Substring edge case (`flask-restful` matching `flask`) flagged in the test as acceptable until a real consumer surfaces concrete pain — premature regex-tightening on speculative cases calcifies wrong.

**Concern.** Extends [PAPER-13](#paper-13). PAPER-13 framed the
`kind: library` mis-classification as a fall-back triggered when
`pyproject.toml` is absent (Django consumer `inthewords` has
`requirements/*.txt` only). PlasmaMapper has a
`pyproject.toml`, declares `fastapi`, `uvicorn`, `sqlalchemy`,
`alembic`, `arq` as runtime deps, and ships three FastAPI/asyncio
entry-point console scripts (`plasma-api`, `plasma-ingest`,
`plasma-worker`) — and `catch-up` still reports
`kind: library`. So the mis-classification is broader than the
"no pyproject.toml" framing in PAPER-13: even when the modern
library-shape indicator is present, the heuristic's fall-back to
`library` fires unless something stronger flips it.

**Why it matters.** The inheritance: `kind` drives the auto-block
content of `docs/CLAUDE.md`, the `.claude/settings.json` defaults,
and (per PAPER-13's resolution sketch) per-kind templates. A
FastAPI consumer mis-classified as `library` ends up with a
CLAUDE.md auto-block that uses library-shaped phrasing
("`do NOT call bootstrap() here`") despite the consumer being a
fully-host-shaped service whose entry-points DO call
`bootstrap()`. Worked-around in
`PlasmaMapper/docs/CLAUDE.md` by overriding the kind in the
project-specific section below the END marker — but that override
is fragile (the BEGIN/END auto-block still claims `library`).

**Concrete evidence.** PlasmaMapper @ `main` / commit `dc8257f`,
mgf-common 0.17.0 (sibling editable install):

```
$ mgf-common catch-up
mgf-common catch-up — auditing /home/bassam/PycharmProjects/PlasmaMapper
  Project:        plasmamapper (kind: library)
  mgf-common:     0.17.0
```

Markers that should have flipped detection to a FastAPI/web kind:

- `pyproject.toml::project.dependencies` includes `fastapi>=0.115`,
  `uvicorn[standard]>=0.30`, `sqlalchemy[asyncio]>=2.0.30`,
  `asyncpg>=0.30`, `alembic>=1.13`, `arq>=0.26`, plus the OTel
  FastAPI / SQLAlchemy / Redis / asyncpg instrumentations.
- `pyproject.toml::project.scripts` declares four console scripts
  (`plasma-api`, `plasma-ingest`, `plasma-worker`, `plasma-cli`)
  pointing at `<package>.<entry>.__main__:main` — application
  entry points, not library exports.
- `src/plasmamapper/api/__main__.py` imports `uvicorn`, calls
  `uvicorn.run(app, ...)`, and brackets it with
  `mgf.common.bootstrap(...)` / `ctx.shutdown()` — host shape, not
  library shape.
- `alembic/` directory + `alembic.ini` + an existing migration
  in `alembic/versions/` — application data layer, not library.
- `docker-compose.dev.yml` for Postgres + Redis + Tempo — service,
  not library.
- `.woodpecker.yml` CI describing `lint / type / test / migrate`
  jobs against Postgres + Redis service containers — service.

**Suggested shape.** Strengthen the heuristic ladder PAPER-13
proposed by checking `pyproject.toml` content (not just presence)
when the file exists:

```python
def detect_kind(root: Path) -> str:
    pp = root / "pyproject.toml"
    if pp.exists():
        deps = _parse_runtime_deps(pp)  # dependencies + optional-deps
        scripts = _parse_console_scripts(pp)  # project.scripts

        # Framework markers (most-specific first).
        if {"fastapi", "starlette"} & deps:
            return "fastapi-app"
        if "django" in deps:
            return "django-app"
        if "flask" in deps:
            return "flask-app"

        # CLI shape — has console scripts, no web framework.
        if scripts:
            return "cli"

        # Default: real library.
        return "library"

    # No pyproject.toml — falls through to PAPER-13's heuristic
    # (manage.py / main.py / Gemfile detection).
    return _detect_kind_no_pyproject(root)
```

Approach A (proposed above): infer from `pyproject.toml` runtime
deps + `project.scripts`. Cheap parse; no AST walk; no false
positives for libraries that import FastAPI as a test dependency
only (since we read `dependencies`, not `optional-dependencies`).

Approach B: a `kind = "fastapi-app"` field consumers declare
explicitly in `[tool.mgf]` or `[tool.mgf-common]` of
`pyproject.toml`. Honest, zero heuristic drift, but gates correct
classification on consumer-provided data — a forgotten declaration
puts the consumer back in the wrong template silently.

**Recommended:** Approach A as default (heuristic), Approach B as
override for projects whose dep tree doesn't reveal the shape
(e.g., a project that vendors FastAPI internally). PAPER-13's
`kind: unknown` fallback still applies for cases where neither
heuristic nor declaration resolves.

---

#### `PAPER-18` — `mgf-common doctor` first-line summary reports a stale hard-coded version, not the imported package's `__version__`

| Field | Value |
|---|---|
| Status | ✅ SHIPPED |
| Severity | 🟢 Nice-to-have |
| Submitter | PlasmaMapper (Phase 1 catch-up adoption, 2026-05-05) |
| Submitted | 2026-05-05 |
| Decision | accept (suggested shape verbatim) |
| Decided on | 2026-05-05 |
| Scheduled for | 0.17.1 |
| Shipped in | `_check_install` in `src/mgf/common/cli/_doctor.py` reads `from mgf.common import __version__` directly instead of `importlib.metadata.version("mgf-common")`. The metadata-version path goes stale during editable-install dev workflows; reading `__version__` from the imported package always reflects the live source tree (per AP-13's pyproject↔__version__ sync rule). |

**Retrospective.** Implemented per the suggested shape. The
existing `importlib.metadata.PackageNotFoundError` fallback was
replaced with an `ImportError` fallback — if `import mgf.common`
fails, the CLI hasn't loaded anyway, but the defensive path
matches the existing error-message style.

**Concern.** `mgf-common doctor` v0.17.0 (sibling editable install)
prints a first line claiming `mgf-common v0.4.0 installed at
(namespace)`. The actual installed version is 0.17.0
(`python -c "import mgf.common; print(mgf.common.__version__)"`
prints `0.17.0`). The number in the doctor banner is decoupled
from the imported package's `__version__` — most likely a
hard-coded string that wasn't bumped during the tree of releases
between 0.4.x and 0.17.x.

**Why it matters.** `doctor` is the diagnostic CLI's "is the
install healthy" surface — the FIRST thing a new adopter runs
after `pip install` to confirm wiring. A stale version on the
first line erodes trust before the rest of the (correct, useful)
output lands. It also actively confuses adopters who paste
`doctor` output into a feedback channel: maintainer reads
"v0.4.0 installed" and starts diagnosing a four-major-versions-old
install instead of the actual one.

**Concrete evidence.** Run on PlasmaMapper @ `main` / commit
`dc8257f`, mgf-common 0.17.0 (sibling editable install,
`pip show mgf-common` confirms `Version: 0.17.0`):

```
$ mgf-common doctor
✓ mgf-common v0.4.0 installed at (namespace)
✓ py.typed marker present (downstream mypy --strict OK)
✓ [observability] extra installed (opentelemetry, sentry_sdk available)
...

$ python -c "import mgf.common; print(mgf.common.__version__)"
0.17.0
```

The offending logic is in the doctor subcommand's banner code; if
the source layout is unchanged from `mgf.common.cli._doctor`,
search for the literal string `"v0.4.0"`.

**Suggested shape.** Read `__version__` from the imported package
at call time, not from a constant in the doctor module:

```python
# mgf/common/cli/_doctor.py — current (presumed):
print(f"✓ mgf-common v0.4.0 installed at {install_path}")

# Fix:
from mgf.common import __version__
print(f"✓ mgf-common v{__version__} installed at {install_path}")
```

If the existing literal isn't a banner string but a "minimum
recognised wheel" sentinel (i.e., used elsewhere as a guard), the
right fix is to source `__version__` for the user-facing banner
and keep the sentinel for whatever logic needed it — they're
separate concerns that drifted into one variable.

**Decision rationale.** *(Filled when triaged.)*

**Retrospective.** *(Filled when shipped.)*

---

#### `PAPER-19` — `mgf-common explain` emits `UnconfiguredWarning` on stderr because the CLI dispatcher hasn't called `bootstrap()`

| Field | Value |
|---|---|
| Status | ✅ SHIPPED |
| Severity | 🟢 Nice-to-have |
| Submitter | PlasmaMapper (Phase 1 catch-up adoption, 2026-05-05) |
| Submitted | 2026-05-05 |
| Decision | accept (Approach A spirit; lighter implementation via `_set_identity`) |
| Decided on | 2026-05-05 |
| Scheduled for | 0.17.1 |
| Shipped in | `main()` in `src/mgf/common/cli/_mgfcli.py` calls `_set_identity("mgf-common-cli", __version__)` before dispatch. Subcommands that read `app_name()` / `app_version()` get the right values; `UnconfiguredWarning` doesn't fire (the warning chain checks `_CONFIGURED` which `_set_identity` flips to `True`); `vunknown` placeholder banners never appear. |

**Retrospective.** Diverged from the filing's Approach A
(`bootstrap_for_test`) on a real concern surfaced during
implementation: `bootstrap_for_test` wires the rotating-file
log handler, which writes to
`~/.local/state/mgf-common-cli/logs/...` on every CLI
invocation. That's the wrong shape for a diagnostic CLI —
side effects on the user's filesystem from `mgf-common
explain` / `doctor` / `standards` would be a different
papercut filed against us next round. Lighter alternative:
`_set_identity` sets the identity globals + flips
`_CONFIGURED=True`, suppressing the warning chain in both
`mgf.common._identity.app_name` and
`mgf.common.current_context` (both gate on the same flag).
No bootstrap, no logging wiring, no filesystem side effects.
Same observable outcome the filing wanted; honest mechanism.

This also surfaced an upstream issue: `MgfSettings` has no
field to disable file logging entirely. Worth filing as a
follow-up if a future consumer hits it (the diagnostic-CLI
shape is the canonical "I want identity + warnings off, no
observability wiring" use case).

**Concern.** `mgf-common explain` (and likely sibling subcommands
that read `MgfSettings` or call `current_context()`) prints
`UnconfiguredWarning: ... called before bootstrap()` on stderr
before producing its output. The warning is the library-correct
behaviour for *consumer code* that reads identity before
bootstrap — but the diagnostic CLI is itself the host running the
read, and it has no consumer to alert.

**Why it matters.** Adopters running `mgf-common explain` to
introspect their settings see a stderr warning that suggests
their install is mis-wired. The fix the warning suggests
(`call mgf.common.bootstrap(app_name=..., app_version=...)`) is
nonsense in the CLI context — they're not the consumer; they're
diagnosing the consumer. The warning is noise on the diagnostic
path, and it's the CLI's responsibility to silence it.

**Concrete evidence.** Run on PlasmaMapper @ `main` / commit
`dc8257f`, mgf-common 0.17.0:

```
$ mgf-common explain 2>&1 | head -3
/home/bassam/PycharmProjects/mgf_common/src/mgf/common/_identity.py:169: \
UnconfiguredWarning: mgf.common.current_context() called before bootstrap() \
— returning None. Downstream `if ctx := current_context():` patterns will \
silently no-op. Call mgf.common.bootstrap(app_name=..., app_version=...) \
at startup, or use mgf.common.bootstrap_for_test() in test fixtures.
  ctx = current_context()
mgf-common v0.17.0  active configuration for app vunknown
```

The trailing banner `for app vunknown` is the second symptom: the
CLI couldn't resolve identity (because no bootstrap), and printed
the placeholder. Two papercuts surfaced by the same root cause.

**Suggested shape.**

**Approach A (recommended) — self-bootstrap in the CLI dispatcher.**
The CLI is itself a host process; it should bootstrap before the
subcommand dispatch runs. The cleanest shape is
`bootstrap_for_test()` in the dispatcher's setup hook:

```python
# mgf/common/cli/__main__.py (or wherever dispatch lives):
def main(argv: list[str] | None = None) -> int:
    # Self-bootstrap so subcommands can read identity / settings
    # without firing UnconfiguredWarning. The CLI is its own host.
    with bootstrap_for_test(app_name="mgf-common-cli",
                            app_version=__version__) as _ctx:
        return _dispatch(argv)
```

Side benefit: the `vunknown` placeholder in the explain banner
disappears, replaced by `mgf-common v0.17.0  active configuration
for mgf-common-cli v0.17.0` — accurate.

**Approach B — suppress the warning category for CLI runs.**
`warnings.filterwarnings("ignore", category=UnconfiguredWarning)`
at CLI startup. Mechanically simpler, but addresses the symptom
not the cause: subcommands like `explain` still see "no active
context" and print `vunknown`, which is its own confusion.

**Recommended: Approach A.** Self-bootstrapping is the honest
fix; the warning never fires; the explain banner gains real
identity; consumers learn by example what the CLI is doing.

**Decision rationale.** *(Filled when triaged.)*

**Retrospective.** *(Filled when shipped.)*

---

#### `PAPER-20` — `mgf-common catch-up --apply` rewrites pyproject pin to a window around the *installed* version, not the latest *PyPI-published* version

| Field | Value |
|---|---|
| Status | ✅ SHIPPED |
| Severity | 🟡 High |
| Submitter | PlasmaMapper (Phase 1 catch-up adoption, 2026-05-05) |
| Submitted | 2026-05-05 |
| Decision | accept (Approach A — refuse rewrite for editable installs) |
| Decided on | 2026-05-05 |
| Scheduled for | 0.17.1 |
| Shipped in | `_is_editable_install()` predicate (reads `direct_url.json` from the dist-info per PEP 660) + guard in `_check_pyproject_pin` that refuses the auto-rewrite when the running mgf-common is editable. The audit row still surfaces staleness; the consumer rewrites the pin by hand against `pip index versions mgf-common`. |

**Retrospective.** Implemented Approach A from the filing.
Approach B (network fetch from PyPI's JSON API) is the
nice-to-have evolution; deferred until a consumer surfaces
real friction with the by-hand walkback. Approach C (opt-in
`--apply-pin` flag) wasn't needed once Approach A's refusal
shape works — the audit row still tells the consumer the pin
is stale, just doesn't auto-rewrite. The pre-existing
`test_apply_bumps_stale_pin` and `test_stale_pin_warns_with_fix`
unit tests now monkeypatch `_is_editable_install → False`
to preserve coverage of the PyPI-install path; a new
`TestEditableInstallPinGuard` class covers the editable-refusal
path.

**Concern.** `catch-up --apply`'s pin-rewrite uses
`mgf.common.__version__` of the **installed** wheel as the basis
for the new pin window. When a consumer dev/CI environment uses
`[tool.uv.sources]` (or `pip install -e ../mgf_common`) for
maintainer-side dogfooding, the installed version is the
sibling-source `__version__` — which can be ahead of the latest
PyPI-published version. The auto-rewrite then produces a pin no
plain-PyPI consumer can install.

**Why it matters.** PlasmaMapper hit this on 2026-05-05:
sibling-installed mgf-common 0.17.0; latest PyPI-published 0.16.0
(v0.16.1 + v0.17.0 are git-tagged but Codeberg-Woodpecker access
was pending so neither has hit PyPI). `catch-up --apply` rewrote
`pyproject.toml::project.dependencies` from `>=0.3,<0.4` →
`>=0.17.0,<0.18`. CI without the sibling override
(`pip install plasmamapper` from a fresh resolver) would fail
because `0.17.0` doesn't exist on PyPI. The walk-back is manual:
edit the pin to `>=0.16,<0.17` after `--apply` already wrote
`>=0.17.0,<0.18`. Adopters who don't notice ship a broken pin.

This is the consumer-side mirror of [PAPER-12](#paper-12) (docs
drift ahead of published wheel): catch-up's pin auto-rewrite has
no PyPI-publish awareness either.

**Concrete evidence.** PlasmaMapper @ `main` / commit `dc8257f`,
sibling editable install (`pip show mgf-common` → `Version: 0.17.0`,
`Location: ../mgf_common/src` — editable). Run captured 2026-05-05:

```
$ mgf-common catch-up
  ⚠️  pyproject.toml — mgf-common pin
      STALE: declared `>=0.3,<0.4` but current mgf-common is `0.17.0` —
      the upper bound excludes it. Suggest bumping to
      `>=0.17.0,<0.18` (2-minor window). Run with --apply to rewrite
      the line; backup written.

$ mgf-common catch-up --apply
  ℹ️  pyproject.toml — mgf-common pin: bumped pin to `>=0.17.0,<0.18`
      in /home/bassam/PycharmProjects/PlasmaMapper/pyproject.toml
      (backup: pyproject.toml.bak.1778035312)

$ pip index versions mgf-common  # PyPI-only resolver
mgf-common (0.16.0)
Available versions: 0.16.0, 0.13.0, 0.12.0, 0.11.3, 0.11.1, 0.4.2, 0.4.1, 0.4.0, 0.3.0, 0.1.0
```

Manual walk-back applied: pin reverted to `>=0.16,<0.17` to match
the PyPI-installable window. PlasmaMapper's
`docs/MGF_ADOPTION.md` §3 documents the override.

**Suggested shape.**

**Approach A — detect editable installs and refuse the auto-rewrite.**
catch-up consults `importlib.metadata.distribution("mgf-common")`
to check whether the install is `editable`. When `True`, the audit
prints the same ⚠️ row but suggests `mgf-common feedback` /
`mgf-common --consult-pypi-window` rather than auto-rewriting.

```python
def _suggest_pin(installed_version: str) -> str:
    is_editable = _is_editable_install("mgf-common")
    if is_editable:
        return (
            f"installed (editable) is {installed_version}; "
            f"NOT auto-rewriting pin — editable installs can run "
            f"ahead of PyPI. Verify the published-PyPI window with "
            f"`pip index versions mgf-common` and update the pin "
            f"by hand (or pass --consult-pypi-window for a network "
            f"resolution)."
        )
    return f"bump to >={installed_version},<{next_minor}"
```

**Approach B — query PyPI for the latest published version.**
catch-up consults
`https://pypi.org/pypi/mgf-common/json` (one HTTP GET, can be
cached) to find the latest published version, and bases the pin
on `min(installed, latest_pypi)`. When the two diverge, the audit
prints both with a hint. Network-dependent; an offline run falls
through to Approach A.

**Approach C (additive) — opt-in only.** Don't auto-rewrite
without an explicit `--apply-pin` flag. The mechanical fixes
(create CLAUDE.md, create .claude/settings.json) stay
auto-applied; the pin rewrite alone gates on the extra flag. This
is the least magical option; consumers walk in knowing the pin
won't change unless they ask.

**Recommended:** Approach A as default (zero network, zero
surprises) + Approach C (opt-in only) on top. Approach B is a
nice-to-have for environments with network. The `catch-up --apply`
output should explicitly list which pin it would have written and
where to verify the published-PyPI window.

This change also obviates the need for the per-consumer comment
block PlasmaMapper now carries above the pin
(`# Pin policy: latest installable on PyPI is 0.16.0; ...`) which
exists only to explain why dev/CI sibling-installed 0.17.0 is OK
despite the apparent pin mismatch.

**Decision rationale.** *(Filled when triaged.)*

**Retrospective.** *(Filled when shipped.)*

---

#### `PAPER-21` — `docs/CLAUDE.md` auto-block's FastAPI quick-start teaches a closed-box leak (`from mgf.common._lifecycle import bootstrap`)

| Field | Value |
|---|---|
| Status | ✅ SHIPPED |
| Severity | 🔴 Blocker |
| Submitter | PlasmaMapper (Round 1 closure verification, 2026-05-05) |
| Submitted | 2026-05-05 |
| Decision | accept (Approach A — one-line template fix + regression guard) |
| Decided on | 2026-05-06 |
| Scheduled for | 0.18.0 |
| Shipped in | One-line fix in `_QUICK_START_BLOCKS["fastapi"]` in `src/mgf/common/cli/_init_project.py` (`from mgf.common._lifecycle import bootstrap` → `from mgf.common import bootstrap`). Plus a regression-guard test class `TestQuickStartBlocksNoClosedBoxLeaks` in `tests/unit/cli/test_init_project.py` that walks every kind template's rendered output through the same `_is_private_module` predicate the catch-up audit uses for consumer src/ — covers all five `_KINDS` (cli / fastapi / django / library / service) plus a PAPER-21-specific assertion pinning the canonical public-import form. Migration guide in [`docs/cutover/v0.18.0.md`](docs/cutover/v0.18.0.md#paper-21--fastapi-template-closed-box-leak-fix). |

**Retrospective.** Approach A from the filing, verbatim. The
audit-and-template asymmetry — the same tool that flags
private-imports in consumer src/ was teaching them in the
auto-block — was the worst-case shape PlasmaMapper called out;
the regression-guard test ensures it can't recur on any
existing kind or any future one. The audit reuses
`_is_private_module` (the predicate already live in
`_check_closed_box_leaks`) for ground truth — no second
mechanism to drift. Approach B (post-process catch-up's
template rendering through the audit at runtime) is
defense-in-depth and remains useful for project-scaffolding
flows other than init-project; deferred until a consumer
hits friction outside the init-project path.

**Concern.** The new `kind: fastapi` template that ships in v0.17.2
(closes PAPER-17, thank you) writes a "Quick start" code block into
the auto-managed section of `docs/CLAUDE.md` whose imports include:

```python
from mgf.common._lifecycle import bootstrap
```

`mgf.common._lifecycle` is a private module (single-leading-underscore
per AP-14). The public name is `mgf.common.bootstrap` — already in
`mgf.common.__all__` (line 50 of `src/mgf/common/__init__.py` on
master). The template is teaching new consumers to import from the
private path, directly contradicting the rule the catch-up audit
already enforces in its closed-box-leak row.

**Why it matters.** This is the worst-case failure mode for an
auto-managed CLAUDE.md block: the same tool that **audits** for
closed-box leaks (`mgf-common catch-up` ✅ closed-box leaks row, shipped
in commit `8cf5ecc` closing PAPER-08) is also **writing** them into
the consumer's session-loaded reference doc. Every Claude session in
a freshly-scaffolded fastapi project starts with the wrong import
pattern in context. Consumers who copy-paste the example into `src/`
will then trip the closed-box-leak audit on their next catch-up run —
audit failing on the very pattern the audit's auto-block taught.

This is also a 🔴 because:
- Severity scales with the number of consumers running catch-up. The
  fastapi-kind template just shipped; every new fastapi consumer
  inherits the leak from session 1.
- Fix is one-line in the template (the import path); zero risk to
  existing call sites.
- The issue is invisible to non-AI-agent reviewers: the `_lifecycle`
  prefix looks like just a sub-module path unless the reader knows
  the closed-box convention.

**Concrete evidence.** PlasmaMapper @ commit `0e5117d` after running
`mgf-common catch-up --apply` against sibling-installed v0.17.2:

```
$ head -185 docs/CLAUDE.md | tail -25
## Quick start — bootstrapping this project

This project depends on `mgf-common`. The canonical entry point
is `mgf.common.bootstrap()`:

\`\`\`python
# src/plasmamapper/app.py
from contextlib import asynccontextmanager
from fastapi import FastAPI
from mgf.common._lifecycle import bootstrap            # ← LEAK
from mgf.common.fastapi import (
    ExceptionTranslationMiddleware,
    RequestIdMiddleware,
)

@asynccontextmanager
async def lifespan(app: FastAPI):
    with bootstrap(app_name="plasmamapper", app_version="0.1.0"):
        yield

app = FastAPI(lifespan=lifespan)
app.add_middleware(ExceptionTranslationMiddleware)
app.add_middleware(RequestIdMiddleware)
\`\`\`
```

The prose immediately above the snippet says "The canonical entry
point is `mgf.common.bootstrap()`" — which is correct — and then the
code block imports from the private module. The two contradict each
other.

Verification of the public name:

```
$ python -c "from mgf.common import bootstrap; print(bootstrap.__module__)"
mgf.common._lifecycle           # actual implementation lives in _lifecycle

$ grep -n "\"bootstrap\"" src/mgf/common/__init__.py
50:    "bootstrap",                # listed in __all__ — public
```

The right import is `from mgf.common import bootstrap`. The
implementation can stay in `_lifecycle`; that's an internal detail
the public re-export exists to hide.

**Suggested shape.** One-line fix in the fastapi-kind template
(`templates/fastapi/CLAUDE.md.j2` or wherever the auto-block source
lives):

```diff
- from mgf.common._lifecycle import bootstrap
+ from mgf.common import bootstrap
```

The same audit applies to every other kind template (`library`,
`cli`, `django`, `flask`) — recommend a one-pass grep over all
template snippets for `from mgf.common\._` imports as a regression
check during template authoring. The closed-box-leak audit's regex
in `_check_closed_box_leaks` is the right ground truth; reuse it
against the rendered templates as a unit test.

**Approach A (one-line fix).** Edit each kind template; pin a unit
test that lints rendered templates with the closed-box-leak audit's
regex. Recommended.

**Approach B (template lint as part of catch-up).** When catch-up
writes a template into a consumer project, post-process the rendered
output through the closed-box-leak audit's regex; fail loudly if
any private import slipped through. Defense in depth — useful as a
safety net even after Approach A lands. Not strictly necessary but
cheap.

**Decision rationale.** *(Filled when triaged.)*

**Retrospective.** *(Filled when shipped.)*

---

#### `PAPER-22` — `mgf.common.alembic.configure_env` does not accept `include_object=` filter, blocking PostGIS / Timescale autogenerate hygiene

| Field | Value |
|---|---|
| Status | ACCEPTED |
| Severity | 🟢 Nice-to-have |
| Submitter | PlasmaMapper (Phase B0 web-adapter trial, 2026-05-05) |
| Submitted | 2026-05-05 |
| Decision | accept (Approach A — additive `include_object=` + `include_schemas=` kwargs) |
| Decided on | 2026-05-07 |
| Scheduled for | v0.19.0 |
| Shipped in | — |

**Concern.** :func:`mgf.common.alembic.configure_env` (v0.18.0) accepts
``database_url`` / ``target_metadata`` / ``compare_type`` /
``compare_server_default`` / ``naming_convention``. It does NOT accept
``include_object=`` — the alembic ``EnvironmentContext.configure`` knob
that lets a project skip specific objects from autogenerate.

**Why it matters.** Postgres extensions install their own internal
tables that consumers don't own and don't want autogenerate to revision.
The canonical case is **PostGIS** — installing the extension creates
``spatial_ref_sys`` (and a small handful of others). Without
``include_object``, every ``alembic revision --autogenerate`` after
PostGIS installation produces a phantom drop migration for these
tables. Same problem applies to TimescaleDB's ``_timescaledb_internal``
schema, pgvector's housekeeping, and any extension that introspects
as plain SQL objects.

PlasmaMapper is the first web consumer to surface this. Our prior
hand-rolled ``alembic/env.py`` had:

```python
include_object=lambda obj, name, type_, reflected, compare_to: (
    not (type_ == "table" and name in {"spatial_ref_sys"})
),
```

The configure_env adapter swap (B0 trial, ~95→25 lines, real win)
loses this filter. We mitigated by documenting the gap inline — the
project disables autogenerate per ADR for migration safety, so we
don't trip the bug today, but any consumer who DOES use autogenerate
+ extensions will trip on the first ``alembic revision``.

**Concrete evidence.** Sibling-installed mgf-common 0.18.0;
PlasmaMapper @ commit `2956724` after Phase B0 swap:

```
$ python -c "
import inspect
from mgf.common.alembic import configure_env
print(list(inspect.signature(configure_env).parameters))
"
['database_url', 'target_metadata', 'compare_type', 'compare_server_default', 'naming_convention']
```

PlasmaMapper's pre-swap env.py (now in git history) carried the
filter callable; the post-swap env.py at
[`alembic/env.py`](https://codeberg.org/magogi-products/PlasmaMapper/src/branch/main/alembic/env.py)
documents the gap inline.

**Suggested shape.** Add ``include_object=`` (and the closely-related
``include_schemas=``) as keyword-only parameters that pass through to
``EnvironmentContext.configure``. Default ``None`` for both — alembic
treats ``None`` as "include everything", matching today's adapter
behaviour for non-extension users:

```diff
 def configure_env(
     *,
     database_url: str,
     target_metadata: Any,
     compare_type: bool = True,
     compare_server_default: bool = True,
     naming_convention: dict[str, str] | None = None,
+    include_object: Callable[..., bool] | None = None,
+    include_schemas: bool = False,
 ) -> None:
```

The public surface grows two names; internal wiring is a one-line
plumb-through to ``context.configure(include_object=include_object,
include_schemas=include_schemas, ...)``.

**Approach A (recommended) — pass-through kwargs.** As above. Smallest
surface; matches alembic's own naming; consumers who don't use
extensions stay zero-config.

**Approach B — typed extension-skip helper.** Ship an
``mgf.common.alembic.skip_extension_tables(*, postgis=False,
timescaledb=False, pgvector=False)`` that returns the right
``include_object`` callable. Higher-level convenience but pre-commits
to a list of supported extensions; first non-listed extension brings
the consumer back to Approach A anyway.

**Recommended:** Approach A as the primitive; Approach B can be
layered on later as a typed convenience that calls into Approach A.
Approach A is what unblocks PlasmaMapper today.

**Cost if deferred past v1.0.** Adding kwargs is additive — safe in any
release. Deferring past v1.0 just means consumers continue to either
(a) rewrite env.py to bypass configure_env when they need autogen
filtering — exactly the boilerplate the adapter exists to absorb, or
(b) disable autogenerate entirely (PlasmaMapper's current stance) and
hand-write every migration.

**Decision rationale.** *(Filled when triaged.)*

**Retrospective.** *(Filled when shipped.)*

---

#### `PAPER-23` — `mgf.common.pytest_plugin` entry-point auto-registration does not survive `uv sync` editable installs

| Field | Value |
|---|---|
| Status | ACCEPTED |
| Severity | 🟢 Nice-to-have |
| Submitter | PlasmaMapper (Phase B0 web-adapter trial, 2026-05-05) |
| Submitted | 2026-05-05 |
| Decision | accept (Approach A — document editable-install caveat in plugin docstring + standards) |
| Decided on | 2026-05-07 |
| Scheduled for | v0.19.0 |
| Shipped in | — |

**Concern.** :mod:`mgf.common.pytest_plugin`'s docstring claims
"Auto-registered via the ``pytest11`` entry point in ``pyproject.toml``.
After installing ``mgf-common`` (any extra), every pytest run sees the
fixtures below without further configuration." In a sibling editable
install via ``[tool.uv.sources]``, this isn't happening — the plugin
isn't auto-loaded, and the documented "no configuration needed"
contract breaks.

**Why it matters.** The strategic-review framing for the plugin (per
the docstring's "Why this exists" section) was: every consumer
reinvents test-fixture boilerplate; auto-registering closes that gap.
When the auto-registration silently fails in editable installs, the
gap reopens for exactly the consumers who are most likely to be
testing against sibling-source — early adopters and the maintainer's
own dogfooding workflow.

**Concrete evidence.** PlasmaMapper @ commit `2956724`, sibling
editable install of mgf-common 0.18.0:

```
$ python -c "from importlib.metadata import entry_points; \
print([e.name for e in entry_points(group='pytest11')])"
['anyio', 'pytest_cov', 'asyncio', 'hypothesispytest']
# Note: no 'mgf_common'

$ pytest --fixtures | grep -B1 mgf_
# (no output — fixtures not registered)

$ pytest -p mgf.common.pytest_plugin --fixtures | grep mgf_
mgf_settings -- ../mgf_common/src/mgf/common/pytest_plugin.py:71
mgf_context -- ../mgf_common/src/mgf/common/pytest_plugin.py:82
mgf_context_with_capture -- ../mgf_common/src/mgf/common/pytest_plugin.py:98
redaction_recorder -- ../mgf_common/src/mgf/common/pytest_plugin.py:123
mgf_otel_provider -- ../mgf_common/src/mgf/common/pytest_plugin.py:165
# ✅ explicit -p loads the plugin and fixtures register correctly
```

The entry point IS declared correctly in
``mgf_common/pyproject.toml`` (``[project.entry-points."pytest11"]
mgf_common = "mgf.common.pytest_plugin"``); the issue is that
``uv sync`` against a ``[tool.uv.sources]`` editable target does not
write the dist-info ``entry_points.txt`` that pytest's plugin
discovery walks at startup.

PlasmaMapper worked around this by adding
``pytest_plugins = ["mgf.common.pytest_plugin"]`` to its
``tests/conftest.py`` — which has the side-effect of being more
discoverable (a reader of conftest sees the dependency explicitly)
but contradicts the "no configuration needed" promise the plugin's
docstring makes.

**Suggested shape.**

**Approach A — document the workaround.** Add a one-paragraph note to
the plugin docstring (and ``mgf-common standards testing``) explaining
that editable installs may need
``pytest_plugins = ["mgf.common.pytest_plugin"]`` in the consumer's
``conftest.py``. Cheap; preserves the "auto-registered on PyPI
install" property; honest about the editable-install caveat.

**Approach B — make pytest discovery resilient.** Investigate why
``uv sync`` skips writing ``entry_points.txt`` for editable installs;
either upstream a fix to ``uv``, or document a ``uv sync --reinstall``
workflow that re-registers entry points. Out of scope for mgf-common,
but worth flagging because the same issue affects every consumer
trying to dogfood sibling-source.

**Approach C — don't rely on entry points.** Drop the ``pytest11``
entry-point declaration; instead document
``pytest_plugins = ["mgf.common.pytest_plugin"]`` as the canonical
opt-in shape. Loses the auto-registration convenience for PyPI
installs but eliminates the silent-failure mode.

**Recommended:** Approach A (document) immediately; Approach B
investigation as follow-up to know whether this is a uv bug or
expected behaviour. Approach C is too aggressive — the auto-loading
on PyPI install IS the right ergonomics for production consumers.

**Decision rationale.** *(Filled when triaged.)*

**Retrospective.** *(Filled when shipped.)*

---

#### `PAPER-24` — `ExceptionTranslationMiddleware` ignores `HttpError.http_status` — HTTP-01 and FastAPI middleware don't compose

| Field | Value |
|---|---|
| Status | ACCEPTED |
| Severity | 🟡 High |
| Submitter | PlasmaMapper (v0.18.0 cutover-guide read, 2026-05-05) |
| Submitted | 2026-05-05 |
| Decision | accept (Approach A — auto-read `http_status` as fallback after status_map miss) |
| Decided on | 2026-05-07 |
| Scheduled for | v0.19.0 |
| Shipped in | — |

**Concern.** v0.18.0 shipped HTTP-01 (`HttpError` + 9 status-bearing
concretes; each carries `http_status: int` as a class attribute) AND
ships `mgf.common.fastapi.ExceptionTranslationMiddleware` (with a
``status_map: dict[type[AppError], int]`` for translation). The two
adapters don't compose: `_lookup_status` walks the MRO looking for
``status_map`` entries only, ignoring `http_status` even when the
exception's class declares it.

The result: even after fully adopting HTTP-01 leaves
(`MyDomainError(HttpNotFound)`), a consumer still has to register
each leaf in ``status_map`` for the middleware to find the 404 — the
information is already on the class, redundantly written into the map.

**Why it matters.** This is the missing keystone in the v0.18.0
architectural batch. HTTP-01 invented `http_status` precisely to make
HTTP-shaped exceptions self-describing; the middleware predates HTTP-01
and doesn't know to look. With auto-read, a project's `status_map`
shrinks to only legacy non-HttpError exceptions; a project that fully
adopts HttpError leaves needs no status_map at all.

This is also the only friction PlasmaMapper found in v0.18.0 worth
acting on. PlasmaMapper's exception hierarchy is small (two tenant
errors today), so we'd happily rebase on `HttpNotFound` if the
middleware composed — but as long as the status_map is mandatory,
rebasing buys us nothing over today's explicit map.

**Concrete evidence.**
`/home/bassam/PycharmProjects/mgf_common/src/mgf/common/fastapi/_exceptions.py:145-158`
(at `mgf-common@6bf3a1e`):

```python
def _lookup_status(self, exc_type: type[AppError]) -> int:
    """Return the status for ``exc_type`` walking MRO until a hit.

    Default to 500 if nothing matches (shouldn't happen — AppError
    is the catch-all in DEFAULT_STATUS_MAP).
    """
    for ancestor in exc_type.__mro__:
        if not isinstance(ancestor, type):
            continue
        if not issubclass(ancestor, AppError):
            continue
        if ancestor in self._status_map:
            return self._status_map[ancestor]
    return 500
```

No `getattr(exc_type, 'http_status', None)` check anywhere. Verified
also via grep: `http_status` does not appear in
`mgf/common/fastapi/_exceptions.py`.

PlasmaMapper's current `status_map` (in
`src/plasmamapper/api/app.py`) extends `DEFAULT_STATUS_MAP` for the
two tenant exceptions:

```python
_PLASMA_STATUS_MAP = {
    **DEFAULT_STATUS_MAP,
    TenantNotFoundError: 404,        # PM-MT-12
    CrossTenantAccessError: 404,     # PM-MT-07 — not 403
}
```

If we rebased `TenantNotFoundError(HttpNotFound, TenantError)`, the
`http_status = 404` would be on the class — but the middleware would
still look only at `_PLASMA_STATUS_MAP` to find it, so the entry
would have to stay. No simplification gained.

**Suggested shape.**

**Approach A — auto-read `http_status` as a fall-back when status_map
misses.** Smallest surface change. ``_lookup_status`` walks the
status_map first (preserving status_map's "I want to override the
default for this type" behaviour); on a miss, checks
``getattr(exc_type, 'http_status', None)``; falls back to 500 if
neither resolves.

```diff
 def _lookup_status(self, exc_type: type[AppError]) -> int:
     for ancestor in exc_type.__mro__:
         if not isinstance(ancestor, type):
             continue
         if not issubclass(ancestor, AppError):
             continue
         if ancestor in self._status_map:
             return self._status_map[ancestor]
+    # No status_map hit. Fall back to the exception's self-declared
+    # http_status if it has one (HTTP-01 leaves carry it as a class
+    # attribute). Default 500 if nothing resolves.
+    declared = getattr(exc_type, "http_status", None)
+    if isinstance(declared, int):
+        return declared
     return 500
```

Backward-compatible (status_map still wins); HTTP-01 leaves work
without registration; legacy non-HttpError types continue to require
status_map entries as today.

**Approach B — auto-read FIRST, status_map as override.** Reverse the
priority: prefer the class-declared `http_status`; let status_map
override only when the consumer explicitly wants different behaviour.
Cleaner mental model ("the exception knows its status; the map is for
exceptions") but breaks the current contract (consumers who set a
status_map entry today and ALSO have `http_status` on the class get
different behaviour after the change).

**Recommended:** Approach A. Status_map's role as an "override here"
mechanism stays; HTTP-01 leaves get the free compose; no consumer
breaks.

**Cost if deferred past v1.0.** `http_status` becomes vestigial — the
attribute exists but the middleware that's most consumers' translation
seam can't see it. Downstream consumers will quietly migrate
HttpError-rooted exceptions BACK to bespoke `AppError`-rooted ones
because the rebase loses status_map entries without saving any
translation logic. The HTTP-01 adoption story collapses.

**Decision rationale.** *(Filled when triaged.)*

**Retrospective.** *(Filled when shipped.)*

---

#### `BOOTSTRAP-01` — `mgf.common.bootstrap()` is structurally not adoptable for Django consumers: `.env` scope clash + no opt-out for stdlib-logging handler installation

| Field | Value |
|---|---|
| Status | ACCEPTED |
| Severity | 🟡 High |
| Submitter | inthewords (Phase 3 adoption walk, 2026-05-07) |
| Submitted | 2026-05-07 |
| Decision | accept (Approach A — both fixes additive: `MgfSettings` `.env` prefix filter + `bootstrap(setup_logging=...)` flag) |
| Decided on | 2026-05-07 |
| Scheduled for | v0.19.0 |
| Shipped in | — |

**Concern.** Two concrete blockers prevent inthewords (and any
Django web app) from adopting `mgf.common.bootstrap()` as the
canonical wiring entry point. Both surface immediately on a
clean adoption attempt.

**(1) `MgfSettings()` reads the project's `.env` file via
pydantic-settings and rejects every Django-side env var as
`extra=forbid`** — even though `MgfSettings.model_config`
correctly sets `env_prefix="MGF_"`. The prefix governs
*environment variable* lookups (so `MGF_LOGGING__LEVEL` →
`logging.level`) but does NOT filter the `.env` file's keys.
pydantic-settings reads every key in `.env` and tries to map
them to MgfSettings fields, failing with:

```
pydantic_core._pydantic_core.ValidationError: 13 validation errors
for MgfSettings
secret_key
  Extra inputs are not permitted [type=extra_forbidden,
  input_value='dev-test-secret-key-not-for-production-use-only']
allowed_hosts
  Extra inputs are not permitted [type=extra_forbidden,
  input_value='localhost,127.0.0.1']
db_name
  Extra inputs are not permitted [type=extra_forbidden,
  input_value='inthewords']
... (10 more)
```

The Django `.env` legitimately holds `SECRET_KEY`, `DB_NAME`,
`DB_USER`, `REDIS_HOST`, `CELERY_BROKER_URL`, etc. — Django's
own settings module reads them. There's nothing wrong with the
project's `.env`. `MgfSettings` is wrong to treat unprefixed
keys as extras when its own config says `env_prefix="MGF_"`.

Workaround: `MgfSettings(_env_file=None)` skips .env loading
entirely. But that defeats the purpose of pydantic-settings's
.env support for any consumer that DOES want to put
`MGF_OTEL__ENABLED=true` in their .env file.

**(2) `bootstrap()` unconditionally installs `StreamHandler` +
`RotatingFileHandler` on the root logger.** Verified
empirically:

```python
import logging, mgf.common
from mgf.common.settings import MgfSettings

# Even with explicit settings, no observability features:
settings = MgfSettings(_env_file=None)  # bypass issue (1)
ctx = mgf.common.bootstrap(
    app_name='inthewords',
    app_version='0.0.0',
    settings=settings,
)
print([h.__class__.__name__ for h in logging.getLogger().handlers])
# ['StreamHandler', 'RotatingFileHandler']
```

There's no `setup_logging=False` flag on `bootstrap()`. The
`bootstrap()` signature (v0.18.0) is just
`(app_name, app_version, *, settings)`. The internal
`mgf.common.observability.setup_logging` is called
unconditionally and installs handlers regardless of settings
values.

For Django apps:
- Django's `LOGGING` dictConfig (in `config/settings/*.py`)
  already configures the project's logging stack at
  settings-module-load time.
- `bootstrap()` runs LATER, in
  `apps/core/apps.py::CoreConfig.ready()` (the natural Django
  seam), and adds two more root handlers.
- Result: every log line is emitted twice (once by Django's
  configured handler, once by mgf's installed handler) AND
  the duplicate uses mgf's `JsonFormatter`, which has the
  deployment-coupled-incompatible JSON shape inthewords
  documented in Phase 5 (per LOG-01 cloud-native carve-out
  — `{ts, level, logger, msg, +flat extras}` vs the project's
  `{timestamp, level, logger, message, extra: {...}}`).

The handlers also include `RotatingFileHandler` writing to a
file under `<state>/<app>/logs/<app>.log` — which on a
cloud-native container is an ephemeral path that vanishes on
restart. Same anti-pattern LOG-01 closed.

**Why it matters.** The strategic-review priority 1
("get a second consumer") explicitly invited
non-pain reports. Phase 3 of the inthewords adoption walk
ran into pain. Both blockers are concrete, reproducible, and
the workarounds for one don't help the other.

The two issues compose: even if (1) is worked around,
(2) still blocks adoption because the unconditional handler
install conflicts with Django's LOGGING dictConfig AND with
the LOG-01 carve-out the project explicitly took. Fixing
either one alone doesn't unblock; both need addressing.

The pattern generalises beyond Django. Any Python application
framework that owns its own logging stack (Flask via
`app.logger`, FastAPI via uvicorn's logging, Pyramid, etc.)
will hit issue (2). Issue (1) generalises to any consumer
whose `.env` is shared with another tool that uses
pydantic-settings (Pydantic-based APIs, SQLAlchemy with
pydantic-validated config, etc.).

**Concrete evidence.** inthewords has 1140 tests in CI and a
production deployment story (Infomaniak Public Cloud →
Grafana Cloud log aggregation). The Phase 5 LOG-01 carve-out
is in the standards now (v0.18.0). The project's logging
config:

- `config/settings/dev.py` — DevFormatter for TTY readability
- `config/settings/prod.py` — `apps.core.logging.JsonFormatter`
  to stdout for Grafana Cloud
- Five `TestJsonShapeContract` regression tests pin the wire
  format (added in commit `955ba30`).

`bootstrap()` adopting AS-IS would break the Grafana parsers
on the next deploy.

**Suggested shape.**

**Approach A (recommended) — both fixes are additive flags
on bootstrap() / MgfSettings.**

1. `MgfSettings.model_config` should respect `env_prefix` for
   .env file scanning too. Concretely: when reading `.env`,
   pydantic-settings (or the MgfSettings construction layer)
   should silently skip keys that don't start with `MGF_`. The
   prefix is the consumer's contract — it tells mgf-common
   "anything not MGF_-prefixed is not yours". Implementation:
   subclass pydantic-settings's source loader, OR use the
   `env_ignore_empty` / `env_nested_delimiter` machinery to
   ignore-but-don't-fail on unprefixed keys.

2. `bootstrap()` grows a `setup_logging: bool = True`
   keyword-only argument. When `False`, the internal
   `observability.setup_logging()` call is skipped. The
   AppContext registration and identity wiring still happen
   (those are the value-add for Django consumers without
   OTel). The function shape becomes:

   ```python
   def bootstrap(
       app_name: str | None = None,
       app_version: str | None = None,
       *,
       settings: MgfSettings | None = None,
       setup_logging: bool = True,   # NEW
   ) -> AppContext:
       ...
       if setup_logging:
           _setup_logging(...)
       # rest of bootstrap unchanged
   ```

   Django consumers call `bootstrap(setup_logging=False)`
   from `CoreConfig.ready()`; their existing LOGGING
   dictConfig stays authoritative.

**Approach B — a separate `mgf.common.bootstrap_minimal()`
entry point.**

A second function that does identity registration + AppContext
without ever touching observability. For consumers who own
their stack and want only the cross-consumer-uniform
identity layer.

```python
def bootstrap_minimal(
    app_name: str | None = None,
    app_version: str | None = None,
) -> AppContext:
    """Like bootstrap() but installs no logging handlers, no
    OTel, no excepthook. For framework consumers (Django,
    FastAPI, Flask, …) that own their observability stack."""
```

**Recommend Approach A.** Single bootstrap() entry point,
opt-out flag is more discoverable than a sibling function.
Doesn't fragment the API surface. The .env-prefix fix is
strictly additive (today's behavior is buggy from the
consumer's perspective; respecting `env_prefix` matches user
expectation).

Approach B is the easier ship if the maintainer wants a
quicker close — adds ~30 lines and doesn't risk breaking
existing consumers' bootstrap call shape.

**Cross-references.**
- inthewords Phase 5 (LOG-01 carve-out, deployment-coupled
  JSON shape rationale): commit `955ba30` in inthewords;
  cutover guide §LOG-01.
- inthewords Phase 3 closeout (this filing's trigger):
  `docs/MGF_COMMON_ADOPTION.md` decisions log entry
  2026-05-07.
- Adjacent: CONFIG-01's framework-native carve-out shipped
  in v0.18.0 acknowledged that Django's `from django.conf
  import settings` is first-class compliant. BOOTSTRAP-01
  extends that recognition to the bootstrap entry point —
  framework-native consumers shouldn't have to fight the
  bootstrap call to opt out of pieces their framework
  already owns.

**Decision rationale.** *(Filled when triaged.)*

**Retrospective.** *(Filled when shipped.)*

---

#### `PAPER-25` — Promote `bootstrap(setup_logging=False)` to a permanent recipe (`docs/recipes/framework-owned-logging.md`)

| Field | Value |
|---|---|
| Status | ACCEPTED |
| Severity | 🟢 Nice-to-have |
| Submitter | inthewords (golden-example pass, 2026-05-07) |
| Submitted | 2026-05-07 |
| Decision | accept (promote BOOTSTRAP-01 cutover-guide content to a permanent recipe with Django + FastAPI + Flask sections) |
| Decided on | 2026-05-07 |
| Scheduled for | v0.20.0 |
| Shipped in | — |

**Concern.** BOOTSTRAP-01's cutover-guide entry (v0.19.0
§BOOTSTRAP-01) is the canonical pattern for any framework
consumer that owns its own logging stack. It applies to
Django (inthewords' use case), FastAPI behind uvicorn (which
configures uvicorn-logging), Flask (`app.logger`), Pyramid,
aiohttp, etc. Currently the pattern lives only in the
v0.19.0 cutover guide — a per-release document.

Cutover guides are **time-bounded** by design (REL-09): they
narrate one release's migration story. The
`setup_logging=False` pattern is **permanent reference**:
every future framework consumer needs the same shape on day
one of their adoption.

**Why it matters.** A FastAPI consumer adopting mgf-common
six months from now will read v0.19.0's cutover guide if
they think to look there, but they more naturally look in
`docs/standards/` or `docs/recipes/`. The
v0.19.0-specific framing ("here's what changed in this
release") doesn't read as "here's the canonical way to wire
bootstrap on a framework consumer."

Promoting to a recipe at
`docs/recipes/framework-owned-logging.md` is a **30-minute
move** of the existing cutover-guide content with framework-
parameterised examples added (Django shown today; FastAPI
+ Flask added).

**Concrete evidence.** inthewords' working bootstrap
adoption is 12 lines in `apps/core/apps.py::CoreConfig.ready()`:

```python
def ready(self) -> None:
    if self._mgf_ctx is None:
        from mgf.common import bootstrap
        from mgf.common.settings import MgfSettings
        self._mgf_ctx = bootstrap(
            app_name='inthewords',
            app_version='0.0.0',
            settings=MgfSettings(),
            setup_logging=False,  # Django owns LOGGING
        )
```

The same shape for FastAPI:

```python
# app/main.py — FastAPI lifespan integration
from contextlib import asynccontextmanager
from fastapi import FastAPI
from mgf.common import bootstrap
from mgf.common.settings import MgfSettings


@asynccontextmanager
async def lifespan(app: FastAPI):
    ctx = bootstrap(
        app_name='myapp',
        app_version=__version__,
        settings=MgfSettings(),
        setup_logging=False,  # uvicorn / project-side logging owns it
    )
    yield
    ctx.shutdown()


app = FastAPI(lifespan=lifespan)
```

For Flask:

```python
# app/__init__.py
from mgf.common import bootstrap
from mgf.common.settings import MgfSettings


def create_app():
    app = Flask(__name__)
    # ... configure_logging(app) etc.
    app._mgf_ctx = bootstrap(
        app_name='myapp',
        app_version=app.config['VERSION'],
        settings=MgfSettings(),
        setup_logging=False,  # Flask's app.logger / dictConfig owns it
    )
    return app
```

The recipe consolidates: the call shape, the placement
across frameworks (Django ready() / FastAPI lifespan / Flask
factory), the rationale (why owned-logging consumers should
opt out), and the OTel-on-day-one note (`setup_logging=False`
preserves OTel auto-init; only logging-handler installation
is skipped).

**Suggested shape.** A single ~80-line markdown file at
`docs/recipes/framework-owned-logging.md`. Sections:

```
# Framework-owned logging — bootstrap(setup_logging=False)

## When to use this pattern
## What setup_logging=False preserves
## Django (AppConfig.ready)
## FastAPI (lifespan context manager)
## Flask (app factory)
## What you give up
## Cross-references (BOOTSTRAP-01, the v0.19.0 cutover guide,
   inthewords' ADR-006)
```

The Django + FastAPI + Flask sections are each ~10-15 lines
of code + 2-3 lines of prose. The "What setup_logging=False
preserves" section is the load-bearing piece — it explicitly
says "identity, OTel auto-init, excepthooks, AppContext
registration are all still wired." Disambiguates from
"setup_logging=False = no observability at all."

**Relationship to ASPIRE-03.** ASPIRE-03 proposes a broader
`docs/standards/web/` subtree where this recipe would live
as `web/bootstrap-framework.md`. PAPER-25 is the **smaller
companion**: even if ASPIRE-03 doesn't ship (or ships
gradually), this one recipe is worth promoting first because
every framework consumer hits it. Triage the two
independently — PAPER-25 is one short PR; ASPIRE-03 is a
multi-PR restructure.

**Cross-references.**
- BOOTSTRAP-01 (shipped in v0.19.0) — the upstream change.
- v0.19.0 cutover guide §BOOTSTRAP-01 §"Combined Django
  adoption" — content to lift verbatim into the recipe's
  Django section.
- inthewords ADR-006:
  `inthewords/docs/adrs/ADR-006-bootstrap-setup-logging-false.md`
  — the per-decision record with full rationale.
- ASPIRE-03 — the broader subtree proposal this is a
  companion to.

**Decision rationale.** *(Filled when triaged.)*

**Retrospective.** *(Filled when shipped.)*

---

### 💭 Aspirational

#### `ASPIRE-01` — "Multi-consumer process" as an explicit design choice

| Field | Value |
|---|---|
| Status | ✅ SHIPPED |
| Severity | 💭 Aspirational |
| Submitter | Reference consumer |
| Submitted | 2026-04-23 |
| Decision | accept (position paper at suggested path) |
| Decided on | 2026-05-01 |
| Scheduled for | shipped in-tree |
| Shipped in | `docs/public/multi_tenancy.md` (in-tree) + cross-link from `01_lifecycle.md` |

**Concern.** The hybrid module-global + opt-in `AppContext`
shape covers most "library-in-library" / multi-consumer
scenarios via PEP 567 contextvar isolation. But the design
question of whether mgf-common explicitly supports or
explicitly forbids multi-tenancy is worth a position paper —
silence is the worst option, since consumers who hit
ambiguity discover it via pain.

**Why it matters.** Decide whether `mgf-common` explicitly
supports or explicitly forbids multi-tenancy as part of the
public-API contract. The current shape supports it implicitly
(scoped `AppContext` context manager), but the contract isn't
documented as a stable promise.

**Suggested shape.** A `docs/public/multi_tenancy.md` walkthrough
that documents the supported scenarios:

  - Library plug-in with its own identity
  - Monorepo tests importing multiple consumer projects
  - Long-running services embedding consumer sub-components

…plus the unsupported ones (e.g., per-request identity in a
single-process server — that's what request-id contextvars are
for, not full identity isolation).

**Resolution.** Shipped as
[`docs/public/multi_tenancy.md`](../docs/public/multi_tenancy.md):
a position paper that commits the project to a specific shape.
The supported scenarios match the suggested list (S1 plug-in
framework, S2 monorepo tests, S3 long-running services with
finite tenants). The unsupported anti-pattern is named
explicitly: per-request identity in a single-process server —
with reasons (cardinality, crash-dir scatter, OTel
service.name semantics) and the right alternative
(`request_id` contextvar via the
fastapi/django middleware that already ships). The contract
table at the end is part of the public-API promise.

---

#### `ASPIRE-02` — Web/Django consumer joins; standards encode CLI/desktop assumptions that need framework-native carve-outs

| Field | Value |
|---|---|
| Status | ✅ SHIPPED |
| Severity | 💭 Aspirational |
| Submitter | inthewords (Phase 0 discovery, 2026-05-05) |
| Submitted | 2026-05-05 |
| Decision | accept (record arrival + close P1 + supply PAPER-10 evidence + unblock PAPER-06) |
| Decided on | 2026-05-06 |
| Scheduled for | 0.18.0 |
| Shipped in | (1) [`docs/internal/STRATEGIC_REVIEW.md`](docs/internal/STRATEGIC_REVIEW.md#priority-1--get-a-second-independent-consumer) Priority 1 marked **✅ ACHIEVED** (2026-05-05). (2) [PAPER-10](#paper-10--no-entry-point-driven-auto-discovery-for-observability--vault-plug-ins) inherits this evidence: criterion 2 ("second consumer reports extension-wiring story") is satisfied with non-pain — `inthewords` reports they don't need entry-point auto-discovery; Django's `app.ready()` is the equivalent dispatch mechanism. PAPER-10 stays DEFERRED. (3) [PAPER-06](#paper-06--docsstandards-cites-the-reference-consumer-with-fileline) is now unblocked for the full restructure into per-consumer reference docs; tracked as a follow-up paper. (4) Per-rule applicability pattern shipped on LG-* (LOG-02) and CF-* (CONFIG-01); generalisation to EH-/DP-/AP- deferred until a future consumer surfaces specific friction. Migration guide in [`docs/cutover/v0.18.0.md`](docs/cutover/v0.18.0.md#aspire-02--second-consumer-arrival-acknowledged). |

**Retrospective.** Two real second-consumer arrivals (`inthewords`
+ `PlasmaMapper`) within hours of each other turned out to be
the inflection point for several deferred items. The closure
shape mirrors what ASPIRE-02's filing prescribed: explicit kind-
aware standards (LG-* + CF-* applies_to columns; per-kind
compliance matrices), consumer-shape acknowledgement (carve-out
sections), reference-consumer restructure unblock (PAPER-06
follow-up). Strategic-review's investment in waiting for the
second consumer paid back across six papers in this release.
The "non-pain counts as evidence" framing from PAPER-10's
deferral note proved load-bearing — `inthewords`'s report that
explicit-call extension-wiring works fine for them is the
strongest signal we'll get without a plugin-framework consumer.

**Concern.** This entry serves three purposes:

1. **Records the second consumer's arrival** — closing the
   strategic-review **Priority 1** gate
   ([*Get a second, independent consumer*](docs/internal/STRATEGIC_REVIEW.md#priority-1--get-a-second-independent-consumer)).
2. **Unblocks PAPER-06's full restructure** — the doc tree's
   "AS-IS in <consumer>" paragraphs can now be generalised to
   `docs/reference-consumers/Project1.md` (VManager) +
   `docs/reference-consumers/Project2.md` (inthewords).
3. **Reports PAPER-10's reopen evidence (criterion 2)** —
   "A second independent consumer lands and reports their
   extension-wiring story." See *Extension wiring* below.

The substantive observation: walking the standards docs end-to-
end, the prescriptive shape is **CLI- or desktop-app-centric**.
VManager is a libvirt VM-management toolkit with CLI + GUI entry
points, on-disk YAML config, vault-stored credentials, and PyPI
distribution. Many of the documented MUSTs encode that shape.
This is hard to see from inside the reference consumer.

A web/Django consumer reveals the assumptions:

- **CF-08** forbids module-global singletons — Django's
  `from django.conf import settings` IS one. (CONFIG-01.)
- **CF-09** mandates a `--config <path>` CLI flag — irrelevant
  to ASGI/WSGI processes. (CONFIG-01.)
- **LG-06** mandates a rotating file sink — anti-pattern in
  cloud-native containers. (LOG-01.)
- **LG-07** mandates a GUI log-viewer dialog — N/A to headless
  services. (LOG-02.)
- **EH-* category taxonomy** is systems-management-domain;
  HTTP-domain consumers have no natural home in the seven
  categories. (HTTP-01.)
- **DP-01 5-layer architectural map** (CLI/GUI →
  orchestration → services → foundation → platform) doesn't
  fit Django web apps (3-layer: views → services → models).
- **REL-* releasing standard** assumes PyPI publishing —
  inthewords is deployed via Ansible, not published.
- **catch-up tooling** assumes `pyproject.toml` and a `library`
  default kind (PAPER-13, PAPER-15).

None of these are blockers — every one has a workaround
(declare N/A, write a carve-out, write a custom shim). But
**every web consumer hits the same set on day one**. The
cumulative cost of "every web consumer re-litigates which
MUSTs apply" is the actual friction; specific instances are
filed as the seven companion entries above (PAPER-13/14/15,
HTTP-01, CONFIG-01, LOG-01, LOG-02).

**Why it matters.** The cornerstone-library positioning leans
on universality. "Industry-standard Python infrastructure
library" implies it works for the dominant Python application
shapes — and the dominant shape on the server side is web/API,
not VM management. The standards being shaped for VManager's
domain (a small but valuable niche) without acknowledging that
shape risks two outcomes:

- Web consumers adopt selectively (Logging + Errors + Catch-up
  tooling), skip the rest, and the library becomes a "logging
  + errors helper" by reputation rather than a cornerstone.
- Web consumers don't adopt at all because the path from
  "we have a Django app" to "we are L1 compliant" isn't
  documented — and the reference-consumer structure makes it
  look like the path doesn't exist.

The high-leverage fix is **explicit kind-aware standards**:
`docs/standards/django/` (or generally `web/`) as a sibling to
the existing CLI/desktop-shaped doc tree. Shared rules stay in
the canonical doc; framework-specific carve-outs and worked
examples live in the per-kind tree.

**Concrete evidence — extension wiring story (PAPER-10
criterion 2).** inthewords does NOT need entry-point auto-
discovery for plugins today. The application is single-tenant
in the multi_tenancy.md sense (one Django process per
container, one consumer identity, no plugin framework on top).

If we adopt mgf-common's observability extension points, the
shape will be:

- One bootstrap call site in `apps/core/apps.py::CoreConfig.ready()`
  (Django's per-process startup hook).
- Any redactor / log-handler / span-processor extensions
  registered explicitly via the documented `register_*` APIs
  in the same `ready()` block.
- Zero entry-point discovery needed. Django app readiness IS
  the equivalent dispatch mechanism.

**Conclusion: non-pain.** PAPER-10's deferral remains correct
for this consumer. We do not file pain against the explicit-
call shape. PAPER-10 may stay deferred until a different
consumer (plugin framework, multi-tenant host) files concrete
need.

**Concrete evidence — adoption surface.** inthewords
(`/home/bassam/PycharmProjects/inthewords`, commit `b9fecfa`,
2026-05-05):

- Django 5.1 / Python 3.12.
- `apps/` flat package tree, 6 sub-apps:
  `core, accounts, rooms, messaging, notifications, api`.
- Already-built central interfaces (Phase 8D, completed):
  - `apps.core.logging.get_logger` + `sanitize_extra` +
    `DevFormatter` + `JsonFormatter` (`apps/core/logging.py`,
    291 LOC).
  - `apps.core.exceptions.AppError` + 9 concrete subclasses
    + `ErrorDetail` + `ErrorCode` + `ErrorLevel`
    (`apps/core/exceptions.py`, 324 LOC).
  - `apps.core.exception_handlers.app_error_drf_handler` +
    `apps.core.middleware.AppErrorMiddleware`.
  - Lint tests in `apps/core/tests/test_central_interfaces.py`
    enforce that no production module bypasses these.
- 950 Python tests + 196 UI tests + 14 smoke tests, all
  green.
- Adoption plan documented in `docs/MGF_COMMON_ADOPTION.md` —
  six phases (discovery → scaffolding → four-gates → bootstrap
  → exceptions migration → logging migration → close-out).
  Phase 0 (discovery) is what surfaced this entry's findings.

The unique value of inthewords as a consumer is exactly this:
we already built parallel implementations of mgf-common's
`AppError` and `get_logger` independently before mgf-common
existed. The migration story is "what happens when a project
that already has the pattern adopts the cornerstone library?"
— not the greenfield wiring VManager went through.

**Suggested shape.**

The seven companion entries (PAPER-13, PAPER-14, PAPER-15,
HTTP-01, CONFIG-01, LOG-01, LOG-02) carry concrete shape
proposals for each specific friction. This entry's suggested
shape is the **structural** answer:

**Approach A — Per-kind standards subtree.**

```
docs/standards/
├── README.md                  # explains kinds + applicability
├── DESIGN_PRINCIPLES.md       # canonical (mostly kind-agnostic)
├── ERROR_HANDLING.md          # canonical + per-kind carve-outs
├── LOGGING.md                 # canonical + per-kind carve-outs
├── CONFIGURATION.md           # canonical + per-kind carve-outs
├── (others)
├── django/                    # NEW: Django-specific extensions
│   ├── README.md
│   ├── ERROR_HANDLING.md      # HTTP categories, DRF integration
│   ├── LOGGING.md             # cloud-native + Channels
│   ├── CONFIGURATION.md       # framework-native settings
│   └── BOOTSTRAP.md           # AppConfig.ready() pattern
├── fastapi/                   # NEW: FastAPI-specific (future)
└── desktop/                   # NEW: VManager-shaped (current default)
```

Each per-kind doc references the canonical rules and adds the
framework's deviations. The canonical docs grow a small
"Applicability" section listing kinds and per-kind exemptions.

**Approach B — Inline carve-out sections in each canonical
doc.**

Each canonical standards doc grows a `## §N — <Topic> for
framework consumers` section that lists the per-kind carve-
outs inline. Less file proliferation; harder to navigate when
a consumer wants the full Django picture.

**Recommend Approach A** for long-term clarity once the
per-kind set has 2+ entries. **Short-term:** Approach B is
fine for inthewords's adoption (one consumer, one carve-out
set per topic). The structural decision can defer until a
third consumer of a different kind shows up.

A standards-doc README change naming the supported kinds —
even just listing them as "the four shapes mgf-common
recognises today: cli, desktop-gui, library-only,
django-app" — is a 30-line edit that immediately reduces
the consumer-side decision burden.

**Decision rationale.** (Filled when triaged.)

**Retrospective.** (Filled when shipped.)

---

#### `ASPIRE-03` — `docs/standards/web/` subtree: distill the two web consumers' adoption walks into a permanent reference

| Field | Value |
|---|---|
| Status | ACCEPTED |
| Severity | 💭 Aspirational |
| Submitter | inthewords (golden-example pass, 2026-05-07) |
| Submitted | 2026-05-07 |
| Decision | accept (Approach A — incremental: ship `docs/standards/web/README.md` navigation page first; per-recipe promotion trickles) |
| Decided on | 2026-05-07 |
| Scheduled for | v0.20.0 (README); per-recipe promotion follows |
| Shipped in | — |

**Concern.** ASPIRE-02 (closed in v0.18.0) committed to
"per-kind standards" as the structural answer to the
framework-fit gap. The web carve-outs that shipped in
v0.18.0 (CONFIG-01, LOG-01, LOG-02, HTTP-01) live as
sub-sections inside the existing topic docs
(`CONFIGURATION.md`, `LOGGING.md`, `ERROR_HANDLING.md`).

After two web consumers (inthewords + PlasmaMapper) and
three release rounds (v0.17 / v0.18 / v0.19), there's enough
crystallised content to justify a **dedicated `docs/standards/web/`
subtree** as the permanent home for web-domain conventions.
The cutover guides have been carrying this content on a
per-release basis; promoting the durable parts to a standards
subtree closes the "where do I look first" gap for any new
web consumer.

**Why it matters.** A FastAPI / Django / Flask consumer
landing on mgf-common today reads `docs/standards/`'s 11
topic docs and has to mentally filter "which of these MUSTs
apply to my framework's shape?". The framework-native
carve-outs are scattered across CONFIGURATION.md §0a,
LOGGING.md §LG-06 cloud-native branch, etc. — discoverable
only by reading every doc.

A `docs/standards/web/` subtree gives the new consumer a
single front door that says "here are the web-specific
conventions, recipes, and caveats." The existing topic docs
stay authoritative for cross-domain MUSTs; the web subtree
is the **narrative wrapper** that says "for a web consumer,
read these in order."

**Concrete evidence.** Three release rounds of consumer
adoption surfaced repeatable patterns that are NOT in the
canonical topic docs but ARE durable across web consumers:

- **The `bootstrap(setup_logging=False)` pattern** (Django
  AppConfig.ready() seam, FastAPI lifespan, Flask
  before_first_request) — currently lives in v0.19.0
  cutover guide §BOOTSTRAP-01. Cutover guides are
  per-release; the pattern is permanent.
- **The django-stubs / DRF / Channels / Celery workaround
  library** — inthewords distilled 10 reusable patterns at
  `docs/standards/web/mypy-stubs-workarounds.md` (in our
  repo). FastAPI hits parallel gaps; the library
  generalises if promoted to mgf-common's standards tree.
- **The framework-native config worked example** (which
  CF-* rules apply, which are N/A, with rationale per
  rule) — currently in CONFIGURATION.md §0a as an
  inthewords-specific example.
- **The cloud-native logging recipe** (stdout-only, JSON
  shape stability, deployment-coupled parser
  considerations) — currently in
  `docs/public/recipes/cloud-native-logging.md` (Phase 5
  / LOG-01 closure). Belongs in the web subtree as the
  canonical reference.
- **The HTTP exception category-catch property** with
  `ExceptionTranslationMiddleware` integration (PAPER-24's
  closure) — FastAPI-specific, currently in cutover-guide
  §PAPER-24.
- **HttpError consumer pattern** — class declarations,
  multi-inheritance for Django (per inthewords' ADR-003),
  status_map vs http_status semantics (per PAPER-24's
  resolution).

Each of these is a **recipe**, not a MUST. The standards
docs mostly carry MUSTs. Recipes belong in
`docs/recipes/` today; the per-kind structuring is the
proposal.

**Suggested shape.**

```
docs/standards/
├── README.md                    # adds a "Per-kind subtrees" §
├── DESIGN_PRINCIPLES.md         # canonical
├── ERROR_HANDLING.md            # canonical (HTTP-01 sub-section stays)
├── LOGGING.md                   # canonical (LG-06 cloud-native branch stays)
├── CONFIGURATION.md             # canonical (§0a framework-native stays)
├── ... (other topic docs)
├── web/                         # NEW: web-domain subtree
│   ├── README.md                # narrative front-door
│   ├── http-exceptions.md       # HTTP-01 hierarchy + PAPER-24
│   │                            # middleware integration + consumer
│   │                            # patterns (Django multi-inherit,
│   │                            # FastAPI subclass)
│   ├── framework-config.md      # CF-* applicability matrix +
│   │                            # the inthewords worked example
│   │                            # (currently in CONFIGURATION.md §0a)
│   ├── cloud-native-logging.md  # LG-06 stdout-only branch + JSON
│   │                            # shape stability + deployment-coupled
│   │                            # parsers (currently the Phase 5 /
│   │                            # LOG-01 closure recipe)
│   ├── bootstrap-framework.md   # BOOTSTRAP-01 setup_logging=False
│   │                            # for Django/FastAPI/Flask/Pyramid
│   │                            # (currently in v0.19.0 cutover guide)
│   ├── stub-gaps.md             # the django-stubs/DRF/Channels/
│   │                            # Celery workaround library (10
│   │                            # patterns from inthewords' Phase
│   │                            # 2.5; FastAPI-specific extensions
│   │                            # added as PlasmaMapper hits them)
│   └── adoption-walk.md         # the canonical onboarding for a
│                                # net-new web consumer — read in
│                                # this order, hit these gates
└── desktop/                     # NEW: VManager-shaped (Qt + libvirt)
    └── ...                      # parallel structure for desktop
                                 # consumers; populated as VManager's
                                 # learnings crystallize
```

**Recipe vs standards distinction.** Standards docs declare
MUSTs/SHOULDs. The web subtree is mostly recipes —
"here's how a Django consumer wires X, here's why."
Following the existing `docs/recipes/` pattern. The subtree
groups them by audience (web consumers) rather than by
topic.

**Recommend Approach A (incremental) — start with
`docs/standards/web/README.md` as a navigation page**
listing the cross-cutting recipes (currently scattered) +
a short narrative for new web consumers. Promote each
recipe from cutover-guide / cross-doc-section into a
standalone file as the maintainer's editing time allows.
The README can ship in one short PR; the per-recipe
promotions trickle in over subsequent releases.

Approach B (big-bang) is the same end-state shipped in
one large PR. Higher review friction; same outcome.

**Cross-references.**
- ASPIRE-02 (shipped v0.18.0): the carve-out approach this
  builds on. The "per-kind standards subtree" idea was
  Approach A in ASPIRE-02's suggested shape but didn't
  ship in v0.18.0 — only the carve-out sections inside
  existing topic docs did. ASPIRE-03 is the follow-on.
- inthewords' `docs/standards/web/mypy-stubs-workarounds.md`
  — the worked example of one such recipe. Drop-in source
  if mgf-common wants to adopt it under the proposed
  subtree.
- BOOTSTRAP-01 cutover-guide §BOOTSTRAP-01 — content for
  `web/bootstrap-framework.md`.
- PAPER-24 cutover-guide §PAPER-24 — content for
  `web/http-exceptions.md`'s middleware-composition section.

**Decision rationale.** *(Filled when triaged.)*

**Retrospective.** *(Filled when shipped.)*

---

#### `ASPIRE-04` — Codify "documented non-adoption" as a first-class consumer outcome (Magogi-foundation principle)

| Field | Value |
|---|---|
| Status | ACCEPTED |
| Severity | 💭 Aspirational |
| Submitter | inthewords (golden-example pass, 2026-05-07) |
| Submitted | 2026-05-07 |
| Decision | accept (standalone principle doc at `docs/standards/principles/non-adoption-is-valid.md` + cross-link from `docs/standards/README.md`) |
| Decided on | 2026-05-07 |
| Scheduled for | v0.20.0 |
| Shipped in | — |

**Concern.** Through the inthewords adoption walk, **two
phases ended in "documented non-adoption"** (Phase 5 logging
and Phase 3 bootstrap pre-v0.19.0):

- Phase 5 — `mgf.common.observability.JsonFormatter` /
  `Redactor` not adopted because the JSON wire shape is
  deployment-coupled (Grafana Cloud parsers depend on the
  project shape) and the redaction key set differs by
  policy (`token` exclusion per project G13-02).
- Phase 3 — `mgf.common.bootstrap()` pre-v0.19.0 not
  adoptable due to `.env` clash + unconditional
  logging-handler install. (BOOTSTRAP-01 closed this in
  v0.19.0; we then DID adopt with `setup_logging=False`.)

In both cases the right answer was a **deliberate
non-adoption with documented rationale**, not a forced
adoption. Both gave mgf-common evidence — Phase 5 produced
the LOG-01 cloud-native carve-out; Phase 3 produced
BOOTSTRAP-01.

The pattern is repeatable. Any consumer encountering a
mgf-common feature whose default behavior conflicts with a
deployment-coupled or policy-specific constraint may
correctly conclude "I'm not adopting this part" — and
**document why**. PlasmaMapper hit similar shape with
PAPER-22 / PAPER-23 (their alembic and pytest-plugin
non-adoptions or workarounds).

**Why it matters.** Without a codified principle,
contributors (especially newer ones) feel pressure to adopt
every standards-bar primitive — even when the local context
makes adoption a *regression*. The strategic-review priority
1 framing ("non-pain reads count as evidence too") hinted at
this in passing. Promoting it to a **named principle** that
new consumers can reference makes the decision-making
explicit:

> **Documented non-adoption** is a first-class outcome of
> standards conformance work. A consumer that consciously
> declines a primitive AND records the rationale — in an
> ADR, a tracker entry, or a feedback paper — is **more**
> conformant than a consumer that adopts blindly and breaks
> a deployment constraint.

The principle has three components:

1. **Conscious decline.** Not "we forgot to adopt" or "we'll
   get to it" — an explicit decision after empirical or
   architectural review.
2. **Documented rationale.** A pointer (ADR, tracker doc,
   feedback paper) that captures the *why* so future
   contributors don't re-litigate.
3. **Feedback if generalizable.** If the rationale points
   at a gap mgf-common could close (like LOG-01 / BOOTSTRAP-01),
   the consumer files a feedback paper. The non-adoption
   becomes a *contribution*.

**Why it matters specifically for mgf-common.** The
"cornerstone library" framing requires consumers across
multiple framework shapes (CLI, GUI, web Django, web
FastAPI, scientific). Some primitives that are right for
one shape are wrong for another — that's the legitimate
domain of carve-outs (CONFIG-01, LOG-01, BOOTSTRAP-01 all
landed because of this). A principle that names "documented
non-adoption is the right outcome when adoption would harm
your context" gives consumers cover to make the call AND
nudges them to file the carve-out so future consumers don't
re-derive.

**Concrete evidence — inthewords' two non-adoptions:**

- ADR-004 (logging non-adoption):
  `inthewords/docs/adrs/ADR-004-logging-non-adoption.md`
  documents the LOG-01 carve-out with empirical evidence
  (the JSON-shape divergence table) and 5 regression tests
  pinning the decision.
- ADR-006 + Phase 3 first closeout: documented non-adoption
  before v0.19.0; flipped to adopted-with-flag after
  BOOTSTRAP-01 shipped. The PROCESS showed both states are
  valid at different times.

**Concrete evidence — PlasmaMapper:**

- PAPER-22 (alembic `include_object` pass-through): they
  filed because their PostGIS context needed it; the
  workaround until then was documented non-adoption of
  alembic autogenerate.
- The "small consumer" framing in PlasmaMapper's
  `MGF_ADOPTION.md` §1: explicitly declines big-consumer
  surface (`VersionedFileStore[T]`, vault backends, async
  logging) — non-adoption is the framing the project leads
  with.

**Suggested shape.**

A new doc at
`docs/standards/principles/non-adoption-is-valid.md` (~150
lines). Sections:

```
# Documented non-adoption — a first-class outcome

## What this principle says
## Three components: conscious decline, rationale, feedback
## When to non-adopt
## When NOT to non-adopt (don't use this as cover for laziness)
## Worked examples (inthewords Phase 5, PlasmaMapper "small consumer")
## How to file the rationale (ADR, tracker entry, feedback paper)
## Cross-references (LOG-01, BOOTSTRAP-01, ASPIRE-02)
```

Plus a one-line cross-reference from
`docs/standards/README.md`'s "Conformance levels" section:
"Documented non-adoption (per
`principles/non-adoption-is-valid.md`) preserves L0/L1/L2
status when the rationale is recorded."

**Alternative shape.** Inline as a new section in
`docs/standards/README.md` rather than a standalone file.
Lower visibility but no fragmentation. Recommend the
standalone file because the principle is meaty enough (the
"when NOT to non-adopt" section especially) to deserve its
own page.

**Cross-references.**
- LOG-01 (shipped v0.18.0) — the cloud-native carve-out
  that emerged from inthewords' Phase 5 documented
  non-adoption.
- BOOTSTRAP-01 (shipped v0.19.0) — both fixes emerged
  from inthewords' Phase 3 documented non-adoption.
- ASPIRE-02 (shipped v0.18.0) — the strategic-review
  priority 1 framing that "non-pain reading is also
  evidence."
- inthewords' ADR-004 + ADR-006 — the worked examples.
- PlasmaMapper's `MGF_ADOPTION.md` §1 "small consumer"
  scope discipline.

**Decision rationale.** *(Filled when triaged.)*

**Retrospective.** *(Filled when shipped.)*

---

## §3. Multi-tenancy decision (design rationale)

The hardest unresolved design question, captured here in full so
the trade-offs survive even if the related entries above don't.

**Option A — "One app per process, explicit."**
- Pro: simple, fast (no contextvar lookup), matches 95% of real use.
- Con: forecloses on library-in-library architectures forever.

**Option B — Per-consumer `AppContext` (full contextvar).**
- Pro: supports every use case.
- Con: every observability function pays a contextvar lookup;
  surface-area doubles (every API needs a sibling that takes a
  context arg).

**Option C — Hybrid (the shipped shape).**
- Module-level globals for the default; `AppContext` as opt-in
  override. Observability resolves via the active context if any,
  else the global.
- Pro: zero overhead for the 95% case; extension available for
  the 5%.
- Con: two paths to reason about (acceptable: the second path is
  documented as a power-user escape hatch).

The Option C shape is what `mgf.common.bootstrap()` +
`AppContext` currently implement. PEP 567 contextvar semantics
give correct async-task isolation + thread isolation without
per-call plumbing. The `current_context()` fast path is a
single ContextVar.get() call (~30 ns).

---

## §4. Reviewer's meta-feedback

Unvarnished personal view from the maintainer + co-design partner.
Take or leave. No frontmatter — these aren't actionable items,
they're trajectory observations.

1. **`mgf-common` is more ambitious than its size suggests.**
   Small today; ambition (industry-standard Python infrastructure
   library) is enormous. Every decision should be made with
   "what would I do if this had 100 consumers" in mind.

2. **The reference-consumer model is a double-edged sword.**
   A co-designed reference consumer gives `mgf-common` a starting
   advantage but biases every decision toward "this works for
   <that consumer>". A second consumer's audit will surface
   assumptions the first can't see. **Recommendation:** even a
   single-file utility that uses `mgf.common.config` only would
   surface assumptions; recruit one early.

3. **The standards docs are heroic and 100% aimed at humans.**
   The "Rules box" pattern is great. But industry-standard also
   means machine-readable: the MUST/SHOULD/MAY set should be
   extractable as a JSON schema so a consumer's CI can run
   `mgf-common-lint` for a structured pass/fail report.

4. **The release flow is a latent risk.** Manual `uv publish`
   from a token file works fine for now. For an industry-standard
   library, plan the path to GPG-signed releases + SLSA provenance
   + a release branch requiring second-maintainer sign-off. Not
   urgent, but plan.

5. **The "cornerstone rhythm" — one shared library + many
   consumers — is the highest-leverage pattern in the project.**
   It captures something most libraries never articulate. Real
   audience is library AUTHORS, not consumers. Consider a separate
   `docs/for-library-authors/` track or a blog post to bootstrap
   the community.

---

## §5. How consumers submit feedback

Any project that depends on `mgf-common` — co-developed sibling,
external adopter, internal team project, hobby user — uses this
file to surface concerns. The submission flow is the same
regardless of relationship to the maintainers.

### Quick-start

```bash
# In a project that has mgf-common installed:
mgf-common feedback              # how to submit + severity guide
mgf-common feedback --template   # the entry template, ready to paste
mgf-common feedback --url        # this file's URL on codeberg
```

### Submission steps

1. **Read §1.4 (entry template) + §1.2 (severity).** The
   uniform shape is what makes the document machine-actionable.
   Don't deviate — the audit trail depends on it.
2. **Pick a fresh ID.** Format `<AREA>-<NN>` where `AREA` is a
   capitalised domain prefix (e.g., `API`, `HTTP`, `DB`,
   `CONFIG`, `SEC`, `TEST`, `VAULT`, `SSH`) and `NN` is a
   2-digit sequence within that area.
3. **Add the entry under §2** (open feedback). §2 is for "this
   is wrong / missing / painful." Roadmap-shaped requests —
   "this whole module would be useful" — also live in §2;
   severity tells you how urgent.
4. **Open a PR.** Two paths depending on access:
   - **Co-developer / sibling-project author**: PR directly
     against `mgf-common`'s `master`.
   - **External consumer (no PR access yet)**: open the
     codeberg PR from a fork. If you can't fork or PR
     (corporate firewall, etc.), open an issue at
     `https://codeberg.org/magogi-admin/mgf_common/issues` with
     "FEEDBACK:" prefix and paste the entry — a maintainer will
     mirror it into FEEDBACK.md and reference your issue.
5. **Triage** happens in the PR thread. The decision (accepted
   / rejected / deferred / scheduled) lands in the entry's
   `Decision` field. The maintainer drives this; submitters
   don't need to push.
6. **When shipped**, the entry's status changes to SHIPPED with
   a one-line retrospective on what was implemented + any
   shape changes. The CHANGELOG carries the full release-level
   detail.
7. **Rejected entries stay in the file** with rationale —
   recording "we considered this and said no, because…" is
   higher-leverage than recording only what shipped.

### What makes a great submission

- **Specific call site or example.** "I tried `MgfSettings(...)`
  with `extra_keys=['foo']` and got a confusing error" beats
  "settings are awkward."
- **Severity self-assessment.** Be honest. A 🔴 means "if we
  lock the API with this, fixing it is breaking later." Most
  things are 🟡 or 🟢; reserve 🔴 for genuine API-shape
  concerns.
- **Suggested shape, even if rough.** A wrong starting point is
  more useful than no starting point. The maintainer can refine.
- **One concern per entry.** Don't bundle. If you have three
  separate sharp edges, file three entries — they'll be triaged
  on different cadences.

### Don't worry about

- **Whether your concern is "important enough."** File it. The
  triage will sort severity. False-negative (concern missed) is
  more expensive than false-positive (entry triaged + closed
  quickly).
- **Whether the maintainer will be annoyed.** This file IS the
  design conversation.
- **Picking the perfect ID.** Maintainers will renumber if
  there's a clash. Get the entry in.

---

## §6. Cross-references

- Engineering standards: [`docs/standards/`](docs/standards/) (8 topic docs).
- Public-API contract: [`PUBLIC_API.md`](PUBLIC_API.md) (the AP-10 contract).
- Vulnerability-reporting policy: [`SECURITY.md`](SECURITY.md) (the SC-12 contract).
- Maintainer's dense-lookup doc: [`docs/claude/CLAUDE.md`](docs/claude/CLAUDE.md).
- Onboarding entry point: [`STARTHERE.md`](STARTHERE.md).
