# 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

#### <a id="PAPER-103"></a>`PAPER-103` — Cross-language-facing payload record schemas are Python-defined; publish them as language-neutral JSON Schema next to the code (generalize the `LogRecord` exemplar to apiprobe's `Finding`)

| Field | Value |
|---|---|
| Status | OPEN |
| Severity | 🟢 Nice-to-have (borderline 🟡 — blocks real cross-language reuse) |
| Submitter | sooth family — cross-family reuse review |
| Submitted | 2026-07-01 |
| Decision | pending |
| Decided on | — |
| Scheduled for | — |
| Shipped in | — |

**Concern.** Across mgf's cross-language-facing repos the *transport / framing* is deliberately language-neutral, but the *payload record schema* is not uniformly so — and a non-Python (e.g. Zig) consumer then has to read Python source to learn the record shape. mgf-common has already **solved this once** for logging: `LogRecord` ships a packaged, language-neutral schema artifact beside its Python class, and logship carries the record inside a neutral HTTP+NDJSON envelope end-to-end. But that exemplar has **not been generalized**: apiprobe's `Finding` — an equally load-bearing, cross-family-desirable *result* schema — is still defined *only* by a Python frozen dataclass (plus prose), with no machine-readable / language-neutral counterpart. Net: the logging payload is portable, the finding payload is not, and no federation rule yet says "load-bearing wire records ship a neutral schema next to the code." (The pattern spans mgf-logship's envelope + mgf-common's `LogRecord` + mgf-apiprobe's `Finding`; filed federation-wide here per the cornerstone convention.)

**Why it matters.** logship's own contract advertises a non-Python consumer as a first-class goal — `_protocol.py:3-6`: *"The contract between any shipper (Python … or a future Zig/TS shipper) and any collector. Deliberately boring: HTTP + NDJSON, so a non-Python runtime can ship logs with nothing but a socket and a JSON encoder."* For logs that promise is **real**: a Zig emitter can pin `log_record_schema.json`. For findings it is **aspirational only**: a Zig/TS tool that wants to emit or consume an apiprobe `Finding` must re-read `core/finding.py`, hand-transcribe the dataclass, and redo it on every field change. The sooth (Zig) family hit exactly this in a cross-family reuse review — `Finding` is a schema we'd want to adopt, but there is nothing language-neutral to adopt *from*.

**Concrete evidence.**
- **Envelope already neutral (good):** `mgf-logship/src/mgf/logship/_protocol.py:3-6` — the contract is "between any shipper (Python … or a future Zig/TS shipper) and any collector … so a non-Python runtime can ship logs with nothing but a socket and a JSON encoder"; `:6-7` — "Source of truth for the framing; the *record* shape inside is mgf-common's canonical `LogRecord` (OB-10), unchanged."
- **`LogRecord` record ALSO already neutral — cite it as the *template*, not the gap:** `mgf-common/src/mgf/common/observability/log_record.py:1` — "Canonical structured log record — the cross-language `LogRecord` schema"; `:6-7` — "one versioned, language-neutral JSON contract, owned here in the cornerstone." Packaged artifact: `mgf-common/src/mgf/common/observability/schema/__init__.py:3,7` — "Holds the two files every non-Python emitter pins against (Wave 1.5)" / "`log_record_schema.json` — the language-neutral schema manifest."
- **`Finding` record is Python-only (the actual gap):** `mgf-apiprobe/src/mgf/apiprobe/core/finding.py:196` defines `Finding` as a frozen dataclass; fields (`:215-243`) are `code`, `severity`, `root_cause`, `message`, `evidence`, `remediation`, `probe_id`, `threat_refs`, `tags`, `since`. Its stated contract is Python + prose only (`finding.py:2` → "DESIGN.md §3.2 (the Finding shape table)"). A repo-wide search finds **no** language-neutral schema artifact for `Finding` — apiprobe's `schema/` package targets the *probed API's* schema, not the finding result (`schema/base.py:46` "`JsonSchemaSource` — future add (raw JSON Schema)").
- **`Finding` already does two things other families want:** redaction-on-construct — `finding.py:245` `__post_init__` → `:259` `scrubbed = _scrub_evidence(self.evidence)` (docstring `:246` "scrub `evidence` through mgf-common's Redactor"), backed by `_mask_url` (`:30` "Mask credentials in a URL string (query-param VALUES + userinfo)"); and a rich `root_cause` taxonomy (`RootCause`, `:138-192`: `network` / `auth` / `rate_limit` / `provider_5xx` / `provider_contract` / … / `unknown`).
- **Format-convergence signal (two-way trade):** sooth publishes its court finding schema as *standards-based* JSON Schema — `sooth-standard/schemas/court_findings_v0_1.schema.json:2` `"$schema": "https://json-schema.org/draft/2020-12/schema"` — carrying two fields mgf's findings lack: `evidence_level` (`:59-62`, `enum: ["VERIFIED_BY_EXECUTION", "VERIFIED_AGAINST_CODE", "VERIFIED_AGAINST_DOCS", "INFERRED"]`) and `done_when` (`:53-57`, "A runnable assertion (digest/test/validator), or a plain statement of why it cannot be mechanized"). Conversely, mgf's redaction-on-construct + `root_cause` taxonomy are things the sooth court would adopt. Note too that `LogRecord`'s existing manifest is **bespoke** (`schema_version` / `core` / `emit_order` / `fields` / `rules`; `log_record_schema.json:2`), not a draft-2020-12 document — consumable, but not by stock JSON-Schema validators/codegen the way sooth's is.

**Suggested shape.** Adopt one federation rule: *every load-bearing, cross-language-facing payload record ships a language-neutral schema next to its code, generated from the type (not hand-kept).* Concretely:
1. **Generalize the `LogRecord` exemplar to `Finding`.** apiprobe ships `schema/finding_schema.json` beside `core/finding.py`, generated from the dataclass + `Severity` / `RootCause` enums (mirroring mgf-common's `LogRecord` generator + a `test_*_conformance` pin), so a Zig/TS consumer can read and validate `code` / `severity` / `root_cause` / `evidence` / … without Python.
2. **Prefer standards-based JSON Schema (draft 2020-12)** for these published record schemas so any language's stock tooling consumes them directly — where sooth already is. Converging lets a single `Finding` schema serve mgf tools and sooth's court. Optionally add a draft-2020-12 *view* of `LogRecord` alongside its pinned manifest (keep the manifest as the generated source of truth).
3. **Two-way schema convergence (secondary).** Borrow `evidence_level` + `done_when` from `court_findings_v0_1.schema.json` into mgf's `Finding` so a finding can state *how* it was verified and *what makes it done*; and let the sooth court adopt apiprobe's redaction-on-construct + `root_cause` taxonomy. The prize is a shared, portable finding vocabulary across the Zig and Python families.

**Decision rationale.** _(pending triage.)_

**Retrospective.** _(pending ship.)_

#### <a id="PAPER-102"></a>`PAPER-102` — AG-18 should ship a `prepare-commit-msg` hook so `git commit -m` carries the AG-03 `Signed-off-by`

| Field | Value |
|---|---|
| Status | ACCEPTED |
| Severity | 🟡 Should-have (fleet-wide AG-03 conformance leak) |
| Submitter | mgf-standard (conformance depth-audit; commit `fadd368`) |
| Submitted | 2026-06-14 |
| Decision | accepted — ship the hook as the dev-convenience layer AND add a CI/PR trailer-gate as the authoritative enforcement |
| Decided on | 2026-06-14 |
| Scheduled for | — (next AG-18 / cookiecutter touch) |
| Shipped in | — |

**Concern.** AG-03 requires *both* trailers (`Signed-off-by:` + `Co-Authored-By:`). AG-18 wires `.gitmessage` via `git config commit.template`, but that template is consulted only for *interactive* commits — `git commit -m` (the default agent / scripted path) bypasses it entirely, so the constant human `Signed-off-by` is silently dropped on `-m` commits. Nothing catches it until a trailer-level audit.

**Why it matters.** The trailers are non-negotiable (review provenance + `git log --grep` audit, per AG-03). AG-18 wiring that covers only interactive commits leaves the *most common* agent path unenforced, so conformance erodes one commit at a time and surfaces only late. Fleet-wide: every repo inherits the same `.gitmessage`-only setup, so every agent committing via `-m` can drop the signoff.

**Concrete evidence.** mgf-standard hit exactly this — **5 commits in one 2026-06-14 session carried only `Co-Authored-By`** (`e976477`, `9684308`, `8fddbfb`, `df285c9`, `14d8129`), caught by the AG-03 row of its per-MUST conformance audit. Fix verified by execution: a committed `.githooks/prepare-commit-msg` (mgf-standard `fadd368`, PR #113) auto-added the signoff on its own `-m` commit, idempotent via `git interpret-trailers --if-exists doNothing`. `evidence_level: VERIFIED_BY_EXECUTION`, `confidence: HIGH`.

**Suggested shape.**
1. **Cookiecutter** ships `.githooks/prepare-commit-msg` (idempotent, merge/squash-safe; adds only the *constant* human signoff — the model-specific `Co-Authored-By` stays agent-supplied), and `mgf-common init-project` sets `git config core.hooksPath .githooks`. `catch-up` repairs it alongside the existing AG-18 `.gitignore` / `.gitmessage` audit.
2. **AG-18 rule text (b):** extend "`.gitmessage` wired via `commit.template`" with "…**and complemented by a committed `prepare-commit-msg` hook** so `git commit -m` (which bypasses the template) still carries the AG-03 signoff."
3. **`mgf-common doctor`:** check `core.hooksPath` is set + the hook present (mechanical enforcement, like the existing `my_stuff/`-ignored check).

Reference implementation — mgf-standard `.githooks/prepare-commit-msg` (PR #113):

```sh
case "$2" in merge|squash) exit 0 ;; esac
git interpret-trailers --if-exists doNothing \
  --trailer "Signed-off-by: $(git config user.name) <$(git config user.email)>" \
  --in-place "$1"
```

**Propagation (DOC-18).** AG-18 rule text + README *What's new* + the `standardized-git-config` recipe + the cookiecutter + `catch-up` / `doctor`.

**Decision rationale.** _Accepted 2026-06-14 (supervisor triage)._ The gap is real
and verified by execution — `commit.template` (AG-18) is consulted only for interactive
commits, so `git commit -m` drops the AG-03 signoff fleet-wide. The proposed
`prepare-commit-msg` hook is the right **dev-convenience** layer: small, idempotent,
proven (mgf-standard PR #113). Two refinements before implementation:

1. **Authoritative enforcement belongs in CI, not (only) the local hook.**
   `core.hooksPath` is *per-clone local config* — a fresh clone (or any commit before
   `catch-up` runs) has no enforcement. Pair the hook with a **PR/CI trailer-gate**: a
   `commit-trailers` `.woodpecker` step that fails if any commit in the push/PR lacks
   **both** `Signed-off-by` AND `Co-Authored-By`. The CI gate is independent of local
   config and gates the *permanent record* at the merge boundary, where AG-03 actually
   matters; the hook just keeps you from getting blocked by auto-filling the constant half.
2. **The hook covers only the constant `Signed-off-by`.** The model-specific
   `Co-Authored-By` still depends on the author — confirmed live while relaying *this*
   paper (a `-m` commit dropped it, caught pre-merge). The CI gate from (1) closes that half.

**Implementation shape** (one cohort change, picked up at the next AG-18 / cookiecutter
touch): cookiecutter ships `.githooks/prepare-commit-msg` + a `commit-trailers` CI step;
`init-project` sets `core.hooksPath`; `catch-up` repairs both; `doctor` checks both;
AG-18(b) rule text names the hook AND the CI gate. WF-13 regenerates the fleet's
`.woodpecker.yml` to add the step. Reference hook: mgf-standard `.githooks/prepare-commit-msg`.

**Progress (2026-06-15).** Slice 1 landed in **mgf-standard PR #118**: the
authoritative-enforcement reference checker `scripts/check_commit_trailers.py`
(+ 10 unit tests), the cookiecutter now ships `.githooks/prepare-commit-msg`
(new repos get the hook), and the `standardized-git-config` recipe documents
both. Slice 2 landed in **mgf-fed PR #20 + mgf-standard PR #119**: the woodpecker
generator now emits a `commit-trailers` step (`gates[...] = True`, all repos)
running the checker, and mgf-standard's own `.woodpecker.yml` self-adopts it
(drift-current). The rest of the fleet is now "pending regen" per
`mgf-fed drift-check` (running CI unaffected — it's a supervisor tool).
Slice 3 landed in **mgf-common PR #34 + mgf-standard PR #120**: `doctor --standards`
gained `_check_commit_hook` (verifies `.githooks/prepare-commit-msg` is present) —
**self-gating** on the pinned corpus's AGENTS text mentioning `prepare-commit-msg`,
so it is inert until AG-18 is tightened (no premature fleet failure); the cookiecutter
now ships both the hook AND the checker (new repos fully wired). _(Design note:
`init-project` is a light scaffolder — no `git init`/cookiecutter — so the hook is
distributed by the cookiecutter + catch-up, and the `core.hooksPath` wiring stays the
per-clone recipe one-liner.)_
Slice 4 landed in **mgf-standard PR #121 (0.1.57) + mgf-common PR #36**: the canonical
hook + checker are now mgf-standard wheel data resolved via `read_scaffold()` (single
source of truth — the cookiecutter copies are drift-pinned to it), and `catch-up
--apply` writes them into a repo from that canonical (degrades gracefully on a corpus
< 0.1.57). **Remaining:** (a) ✅ **mgf-standard 0.1.57 published** (2026-06-15, `mgf-fed release-remote`,
REST 200) — `read_scaffold` now resolvable fleet-wide; (b) **fleet cohort wave** — bump each
repo's `mgf-common[standards]` floor to ≥ a corpus with `read_scaffold`, run `catch-up --apply`
(distributes hook+checker), regenerate every non-exempt `.woodpecker.yml` (the commit-trailers
step + the `[tool.mgf-fed]` comment); (c) **AG-18 rule-text tightening LAST** (flips the doctor
check + fleet woodpeckers to enforcing), only once every repo has the mechanism. Status stays
ACCEPTED until (b)–(c) ship.

**Retrospective.** _(pending ship.)_

#### <a id="PAPER-03"></a>`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/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/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/reference/observability.md` "Related
docs".

---

#### <a id="PAPER-04"></a>`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/recipes/versioned.md`. Pinned by 28 unit
tests + 5 user-perspective tests + the `PUBLIC_API.md` /
`PUBLIC_API.json` contract.

---

#### <a id="PAPER-06"></a>`PAPER-06` — `docs/standards/` cites the reference consumer with file:line

| Field | Value |
|---|---|
| Status | ✅ SHIPPED (full restructure; partial caveat banner generalised) |
| Severity | 🟡 High (doc refactor) |
| Submitter | Reference consumer |
| Submitted | 2026-04-23 |
| Decision | accept partial 2026-04-30; full restructure 2026-05-10 (unblocked by ASPIRE-02's "second consumer arrived" event) |
| Decided on | 2026-04-30 / 2026-05-10 |
| Scheduled for | partial: 0.18.0; full: 0.32.0 |
| Shipped in | **v0.18.0 (partial):** line ranges stripped from 12 citations across 6 docs; caveat banner in `docs/standards/README.md`. **v0.32.0 (full):** new `docs/reference-consumers/` folder with an index + three per-consumer profile files (`vm-vmanager-app.md`, `inthewords.md`, `PlasmaMapper.md`). Each profile documents the consumer's project type + shape-at-a-glance + pattern-by-pattern evidence + known non-adoptions + the friction it surfaced as papers. `docs/standards/README.md`'s caveat banner generalises from "reference consumer = vm-vmanager-app" to "three reference consumers; see `reference-consumers/`". The standards docs themselves stay consumer-neutral; inline mentions of consumer names that survive the restructure are evidence-grounding (one clause, no file:line) rather than "AS-IS in X" paragraphs. |

**Retrospective.** The full restructure was held until a second
consumer arrived (per the partial's "deferred" note) — ASPIRE-02 in
v0.18.0 marked that gate met (inthewords), and PlasmaMapper made it
three. With three independent shapes documented side-by-side
(desktop / Django / FastAPI-SaaS), the "the standards are
consumer-shaped" perception risk is closed structurally — no one
reading the standards docs will mistake them for descriptions of
one project's habits when the per-consumer evidence is in a
clearly-labelled separate folder.

The profile shape settled on a consistent outline: project type →
shape-at-a-glance table → pattern-by-pattern evidence (config,
logging, errors, subprocess, etc.) → known non-adoptions with
rationale → friction-surfaced-as-papers list. New consumers slot in
by mirroring that outline; the index README.md documents the
"wait for evidence; mirror the structure; cross-reference from
standards; add a row" intake flow.

Inline mentions of consumer names in `docs/standards/*.md`
intentionally stayed in place — they're one-clause evidence
groundings ("vm-vmanager-app's `Service` lifecycle uses this pattern"),
not the heavy "AS-IS in <consumer>" paragraphs the paper targeted.
Removing every inline mention would have hollowed out the standards'
"grounded in real code" stance; keeping them while moving the
detailed shape into `reference-consumers/` is the right partition.

The `docs/reference-consumers/` folder is also the natural home
for future consumer profiles (`mgf-apiprobe` if its shape diverges
enough; whatever Magogi-foundation marketing-site stands up via
mgf-website-template at the language boundary — though THAT will
probably profile differently per ASPIRE-05). The folder's README
documents the intake flow explicitly so adding a fourth or fifth
profile doesn't require re-litigating the structure.

**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.

---

#### <a id="PAPER-07"></a>`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 (vm-vmanager-app 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`.

---

#### <a id="PAPER-08"></a>`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 (vm-vmanager-app 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
vm-vmanager-app'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.

---

#### <a id="PAPER-09"></a>`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 | vm-vmanager-app (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.

vm-vmanager-app'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 vm-vmanager-app 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.

---

#### <a id="PAPER-10"></a>`PAPER-10` — No entry-point-driven auto-discovery for observability / vault plug-ins

| Field | Value |
|---|---|
| Status | DEFERRED |
| Severity | 🟢 Nice-to-have |
| Submitter | vm-vmanager-app (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.** vm-vmanager-app 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/inprogress/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/design/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.

vm-vmanager-app 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
vm-vmanager-app'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.

---

#### <a id="PAPER-11"></a>`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 | vm-vmanager-app (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 vm-vmanager-app'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 vm-vmanager-app'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.

---

#### <a id="PAPER-12"></a>`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 | vm-vmanager-app (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 vm-vmanager-app's catch-up the
consumer read `mgf_common/PUBLIC_API.md` and
`docs/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.

---

#### <a id="PAPER-13"></a>`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 —
vm-vmanager-app 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.

---

#### <a id="PAPER-14"></a>`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.

---

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

| Field | Value |
|---|---|
| Status | ✅ SHIPPED (full audit; partial info-level workaround replaced) |
| Severity | 🟡 High |
| Submitter | inthewords (Phase 0 discovery, 2026-05-05) |
| Submitted | 2026-05-05 |
| Decision | accept partial v0.17.2 (improved warning); full audit v0.32.0 (first v0.32+ stabilization-window release) |
| Decided on | 2026-05-05 / 2026-05-10 |
| Scheduled for | 0.17.2 (partial); 0.32.0 (full) |
| Shipped in | **v0.17.2 (partial):** `_has_pip_tools_layout` + tailored info-level message ("workaround: add a minimal pyproject.toml"). **v0.32.0 (full):** `_check_pin_in_requirements` reads the pin from `requirements/base.txt` (fallbacks: `main.txt` / `runtime.txt` / flat `requirements.txt`) and applies staleness + auto-rewrite + editable-install guard uniformly with the pyproject path. `_check_four_gates_standalone` reads the four gates from `ruff.toml` / `.ruff.toml`, `mypy.ini` / `.mypy.ini` / `setup.cfg [mypy]` (strict=True), `pytest.ini` / `setup.cfg [tool:pytest]` / `tox.ini [pytest]` (filterwarnings=error, single- + multi-line forms), `.coveragerc` / `coverage.ini`. The historical `_check_pyproject_pin` / `_check_four_gates` entry-point names are kept; they dispatch internally based on `_has_pip_tools_layout`. Audit rows for pip-tools projects are titled `requirements/* — mgf-common pin` / `… four gates`. 17 new unit tests under `tests/unit/cli/test_catch_up.py`. Cutover guide: [`docs/cutover/v0.32.0.md`](docs/cutover/v0.32.0.md). |

**Retrospective.** The full ask landed almost exactly as suggested
in the filing — manifest detection ladder (pyproject → pip-tools
requirements → flat requirements.txt → no manifest), pin reading
from `requirements/base.txt` with synonym fallbacks, four-gate
audit from standalone config files. Staleness detection +
auto-rewrite + the PAPER-20 editable-install guard all inherit
from the pyproject path uniformly, so a pip-tools project gets the
same audit experience as a pyproject one. The audit row labels
changed from `pyproject.toml — …` to `requirements/* — …` for
pip-tools projects, which closes the cosmetic "the row title
talks about a file that doesn't exist" papercut the v0.17.2
partial inherited.

One bug surfaced during implementation: the original regex
`^(mgf-common(?:\[[^\]]+\])?)\s*([^\s#]*)` used `\s*` for the gap
between the package name and the constraint. `\s*` consumes
newlines, so a bare `mgf-common\nrequests>=2.31\n` line read the
second line's content as the constraint string. Replaced with
`[ \t]*` (same-line whitespace only); applied to both the reader
and the rewriter. The bug existed but was unreachable in the
pyproject path (which uses TOML parsing, not regex), so no
back-port to earlier releases is needed.

The four-gate standalone audit covers the cross-product of
{ruff, mypy, pytest, coverage} × {single-file config, setup.cfg-
embedded section, tox.ini-embedded section} that pip-tools
projects naturally use. The configparser library does the parsing
in every case, so multi-line INI continuation values (pytest's
`filterwarnings = \n    error \n    ignore::DeprecationWarning`)
work without extra effort. mypy's `strict = True` is checked for
truthiness (also accepts `true` / `1` / `yes` / `on`).

This is the **first v0.32+ stabilization-window release** — per
the federation roadmap, v0.32+ adds no new mgf-common public
surface, but behaviour fixes to existing tooling like catch-up
land cleanly. The mgf-fastapi v0.3 release (PAPER-29) sits
alongside as the first sibling new-surface release under the
independent-semver rule.

**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."

---

#### <a id="HTTP-01"></a>`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/archive/v0.18.0.md`](docs/cutover/archive/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 — vm-vmanager-app'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).

---

#### <a id="CONFIG-01"></a>`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/archive/v0.18.0.md`](docs/cutover/archive/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 — vm-vmanager-app has its
own `AppConfig` Pydantic class that maps to CF-* cleanly
because vm-vmanager-app 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).
```

---

#### <a id="LOG-01"></a>`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/recipes/cloud-native-logging.md`](docs/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/archive/v0.18.0.md`](docs/cutover/archive/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 vm-vmanager-app-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.

---

#### <a id="LOG-02"></a>`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/archive/v0.18.0.md`](docs/cutover/archive/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).

---

#### <a id="PAPER-16"></a>`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.)

---

#### <a id="VAULT-01"></a>`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/reference/vault.md` ("Custom backend with
a typed config — recommended"). Pinned by 8 unit tests + 1
public-surface test.

---

#### <a id="PAPER-17"></a>`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.

---

#### <a id="PAPER-18"></a>`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.)*

---

#### <a id="PAPER-19"></a>`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.)*

---

#### <a id="PAPER-20"></a>`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.)*

---

#### <a id="PAPER-21"></a>`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/archive/v0.18.0.md`](docs/cutover/archive/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.)*

---

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

| Field | Value |
|---|---|
| Status | ✅ SHIPPED |
| 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 | v0.19.0 — `mgf.common.alembic.configure_env` now accepts two keyword-only parameters that pass straight through to `alembic.context.configure(...)`: `include_object: Callable[..., bool] \| None = None` (filter callable) and `include_schemas: bool = False`. Defaults match alembic's own defaults — no behaviour change for current users. Migration guide in [`docs/cutover/archive/v0.19.0.md`](docs/cutover/archive/v0.19.0.md#paper-22--alembic-include_object-pass-through). |

**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.** Approach A (pass-through kwargs) — smallest
surface, matches alembic's own naming, consumers without extensions
stay zero-config. Approach B (typed extension-skip helper) deferred:
pre-commits to a list of supported extensions, and the first
non-listed extension brings the consumer back to Approach A anyway.

**Retrospective.** Approach A from the filing, verbatim. PostGIS /
TimescaleDB / pgvector consumers can plug their existing
`include_object` callable directly into `configure_env` without
rewriting the env.py around it. PlasmaMapper's pre-swap callable
(`spatial_ref_sys` skip) was the canonical use case driving the
design; if/when they flip autogenerate back on after their
migration-safety ADR review, it plumbs through unchanged. Strictly
additive with safe defaults — no migration burden for consumers
who don't use extensions.

| Field | Value |
|---|---|
| Status | ✅ SHIPPED |
| 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 | v0.19.0 — documentation-only. Editable-install caveat added to the `mgf.common.pytest_plugin` docstring + [`docs/recipes/testing.md`](docs/recipes/testing.md). Opt-in form: `pytest_plugins = ["mgf.common.pytest_plugin"]` in consumer `tests/conftest.py`. PyPI-installed consumers are unaffected — the entry point fires during `pip install` / `uv pip install`. Migration guide in [`docs/cutover/archive/v0.19.0.md`](docs/cutover/archive/v0.19.0.md#paper-23--pytest_plugin-editable-install-caveat-documented). |

**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.** Approach A (document the workaround). The
"auto-registered on PyPI install" property holds for production
consumers — that's the contract worth preserving. The editable-install
silent-failure mode is upstream behaviour (`uv sync` against
`[tool.uv.sources]` editable targets does not write the dist-info
`entry_points.txt`); a one-paragraph docstring note + the canonical
opt-in shape closes the discoverability gap without destabilising
the entry-point ergonomics. Approach C (drop the entry point
entirely) was rejected as too aggressive.

**Retrospective.** Approach A from the filing. PlasmaMapper's
existing `pytest_plugins = ["mgf.common.pytest_plugin"]` workaround
in `tests/conftest.py` is now the documented opt-in for editable
installs — the docstring + testing recipe match what they did. No
code change. Approach B (upstream investigation into uv's editable
`entry_points.txt` behaviour) deferred until a maintainer has
capacity; the workaround is durable and more discoverable than the
entry-point form anyway (a reader of conftest sees the dependency
at a glance).

| Field | Value |
|---|---|
| Status | ✅ SHIPPED |
| 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 | v0.19.0 — `mgf.common.fastapi.ExceptionTranslationMiddleware._lookup_status` now resolves in this order: (1) `status_map` entries for any non-`AppError` ancestor (the explicit override seam — `AppError` itself deferred because it acts as a catch-all and would mask the class-declared status), (2) class-declared `HttpError.http_status` (HTTP-01 leaves self-describe), (3) `status_map[AppError]` catch-all (default 500). Backward compatible — explicit `status_map` entries still win. Migration guide in [`docs/cutover/archive/v0.19.0.md`](docs/cutover/archive/v0.19.0.md#paper-24--exceptiontranslationmiddleware-reads-http_status). |

**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.** Approach A (auto-read `http_status` as fallback
after status_map miss). Status_map's role as an "override here"
mechanism is preserved; HTTP-01 leaves get the free compose; no
existing consumer breaks. Approach B (auto-read FIRST, status_map as
override) was rejected because it changes behaviour for any consumer
who already has both a `status_map` entry AND `http_status` on the
class.

**Retrospective.** Approach A from the filing, verbatim. The keystone
fix: HTTP-01 invented `http_status` to make HTTP-shaped exceptions
self-describing, but v0.18.0 didn't wire the middleware to read it.
v0.19.0 closes the same-release-introduced regression in the
next-release window — the cutover-guide pattern caught it on
PlasmaMapper's first read-through of v0.18.0, before any production
consumer hit the silent rebase-buys-nothing failure mode. The pattern
is paying for itself. Consumers rooting domain exceptions in
`HttpError` subclasses can now drop redundant `status_map` entries;
PlasmaMapper's `_PLASMA_STATUS_MAP` for `TenantNotFoundError` /
`CrossTenantAccessError` is the canonical first-beneficiary case.

| Field | Value |
|---|---|
| Status | ✅ SHIPPED |
| 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 | v0.19.0 — two additive changes. **(1)** `mgf.common.config.settings_config()` now defaults `dotenv_filtering="match_prefix"` — `BaseAppSettings` subclasses with a non-empty `env_prefix` silently ignore unprefixed `.env` keys instead of routing them to `extra="forbid"` and raising `ValidationError`. Behaviour change for `extra="allow"` subclasses that relied on the legacy permissive dotenv: pass `dotenv_filtering=None` in `model_config` to restore the old shape. **(2)** `bootstrap()` grows a keyword-only `setup_logging: bool = True` parameter; when `False`, the StreamHandler + RotatingFileHandler install + per-component `level_overrides` are skipped. Identity, OTel, excepthooks, AppContext registration, and the bootstrap-log buffer replay still happen. Default `True` preserves v0.18 behaviour. Permanent recipe (post-v0.20.0): [`docs/recipes/framework-owned-logging.md`](docs/recipes/framework-owned-logging.md). Migration guide in [`docs/cutover/archive/v0.19.0.md`](docs/cutover/archive/v0.19.0.md#bootstrap-01--django-adoption-blocker). |

**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.** Approach A — both fixes are additive flags
with safe defaults. Single bootstrap() entry point with an opt-out
flag is more discoverable than a sibling `bootstrap_minimal()`
function (Approach B) and doesn't fragment the API surface. The
`.env`-prefix fix is strictly additive: today's behaviour was buggy
from the consumer's perspective; respecting `env_prefix` matches
user expectation and unblocks every consumer with a multi-tool
`.env`.

**Retrospective.** Approach A from the filing, verbatim. inthewords
Phase 3 unblocked. The pattern generalises beyond Django — Flask
(`app.logger`), FastAPI/uvicorn, Pyramid, aiohttp all use the same
`setup_logging=False` opt-out for framework-owned logging, and
PAPER-25 promotes the worked example to a permanent recipe in
v0.20.0. The `dotenv_filtering="match_prefix"` default benefits
every `BaseAppSettings` subclass with a non-empty `env_prefix`;
consumers with single-tenant `.env` are unaffected. Two structural
blockers closed in one release; the BOOTSTRAP area opens for future
framework-fit work.

---

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

| Field | Value |
|---|---|
| Status | ✅ SHIPPED |
| 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 | v0.20.0 — permanent recipe at [`docs/recipes/framework-owned-logging.md`](docs/recipes/framework-owned-logging.md) (originally landed at `docs/public/recipes/framework-owned-logging.md`; v0.25.0 doc-restructure flattened to current path). Sections: *When to use* (four "yes if" triggers + three "no if" cases); *What `setup_logging=False` preserves* (table comparing the two paths across nine wiring steps); *Django (`AppConfig.ready()`)*; *FastAPI (lifespan context manager)*; *Flask (app factory)*; *What you give up* (three explicit trade-offs); *Cross-references* to BOOTSTRAP-01 + the v0.19.0 cutover guide + the web subtree. Migration guide in [`docs/cutover/archive/v0.20.0.md`](docs/cutover/archive/v0.20.0.md#paper-25--framework-owned-logging-recipe). |

**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.** Accept and ship in v0.20.0. Cutover guides
are time-bounded by REL-09; framework-owned logging is durable.
Every future framework consumer needs the same shape on day one of
their adoption. Original-path note: PAPER-25 suggested
`docs/recipes/framework-owned-logging.md`; the v0.20.0 ship used
the existing convention `docs/public/recipes/`, and the v0.25.0
doc-restructure later flattened it back to `docs/recipes/`. End
state matches the filing's suggested path.

**Retrospective.** BOOTSTRAP-01's cutover-guide content promoted
to permanent reference at the canonical recipe path. The "What
`setup_logging=False` preserves" table is the load-bearing piece —
it disambiguates the flag from "no observability at all" by listing
the nine wiring steps that still happen (identity, OTel, excepthooks,
AppContext, buffer replay) versus the two that don't (handler
install, level overrides). The recipe is now the canonical pointer
for any FastAPI / Django / Flask / Pyramid consumer adopting
mgf-common — they read this file, not the v0.19.0 cutover guide.

---

#### <a id="PAPER-26"></a>`PAPER-26` — `mgf.common.fastapi.webhooks.HmacWebhookVerifier` — generic Svix-shaped HMAC verification primitive

| Field | Value |
|---|---|
| Status | ✅ SHIPPED |
| Severity | 🟡 |
| Submitter | PlasmaMapper (commit `45dbb25`) |
| Submitted | 2026-05-08 |
| Decision | accept (Svix-only at v1; transport-agnostic core + FastAPI request adapter; `HmacVerificationError(HttpUnauthorized)` single-inherit; force-lowercase header lookup; replay-cache hook deferred) |
| Decided on | 2026-05-08 |
| Scheduled for | v0.26.0 |
| Shipped in | v0.26.0 — new subpackage `mgf.common.fastapi.webhooks` with four public names: `WebhookHeaderSchema` (frozen dataclass; case-folds header names), `SVIX_SCHEMA` (Svix preset; used verbatim by Clerk), `HmacWebhookVerifier` (transport-agnostic core; thread-safe; verifies HMAC-SHA256 with replay window + multi-version sigs + constant-time compare), `verify_request` (async FastAPI Request adapter). New public exception `mgf.common.exceptions.HmacVerificationError(HttpUnauthorized)` — PAPER-24's `http_status` resolution maps to 401 without a `status_map` entry. Permanent recipe at `mgf-fastapi/docs/recipes/webhooks.md` (the webhooks surface + recipe moved to the `mgf-fastapi` sibling at v0.28 per ASPIRE-06; this entry's "Shipped in" records the original v0.26 mgf-common location). Migration guide in [`docs/cutover/v0.26.0.md`](docs/cutover/v0.26.0.md#paper-26--hmac-webhook-verifier). 25 tests across every failure mode (missing header, unparseable timestamp, replay window in both directions, tampered body, wrong secret, wrong/no prefix, multi-version sig key rotation, case-insensitive headers, custom schema, custom signature prefix) plus integration through `ExceptionTranslationMiddleware`. |

**Concern.** Every web consumer of mgf-common ends up writing the same HMAC
webhook verification dance — Svix-style: read `<id-header>`, `<ts-header>`,
`<sig-header>`, validate the timestamp against a 5-minute replay window,
compute `HMAC-SHA256(<id>.<ts>.<body>)` with the shared secret, base64 it,
and `hmac.compare_digest` against a space-separated list of versions. PlasmaMapper
ships this code **twice** in Phase 3 (in `MockAuthProvider` for tests and in
`ClerkAuthProvider` for production), at ~50 LOC each. The two are structurally
identical and stay in sync only by review discipline. Stripe, Clerk, Slack,
GitHub, Linear, and Resend all use Svix-flavoured signing schemes (the header
names differ but the algorithm is one of three patterns); a generic verifier
under `mgf.common.fastapi.webhooks` would absorb the duplication for every
consumer.

**Why it matters.** This is the second-most-load-bearing security boundary in a
SaaS app (after session JWT verification). Subtle bugs — missing timestamp
check, non-constant-time comparison, accepting a stale signature, decoding the
body before verifying — all show up in real-world incident reports. Concentrating
the implementation in mgf-common with a single test surface is exactly the
mgf-common DP-02-shaped risk-reduction this library was built for. PlasmaMapper
is the first web-shaped consumer to hit this; subsequent web consumers (per
ASPIRE-02 / ASPIRE-03) will hit it too. Filing now so the abstraction lands
before three consumers diverge on header conventions.

**Suggested shape.**

```python
# mgf.common.fastapi.webhooks
from typing import Protocol

class WebhookHeaderSchema(Protocol):
    """How a provider names the three Svix-style headers."""
    id_header: str        # e.g., "svix-id" / "stripe-event-id" / "x-github-delivery"
    timestamp_header: str # e.g., "svix-timestamp" / "stripe-signature" (mixed)
    signature_header: str # e.g., "svix-signature" / "x-hub-signature-256"

SVIX_SCHEMA = WebhookHeaderSchema(
    id_header="svix-id",
    timestamp_header="svix-timestamp",
    signature_header="svix-signature",
)
# Plus presets: STRIPE_SCHEMA, GITHUB_SCHEMA, ...

class HmacWebhookVerifier:
    def __init__(
        self,
        *,
        secret: bytes,
        schema: WebhookHeaderSchema = SVIX_SCHEMA,
        replay_window_seconds: int = 5 * 60,
        signature_prefix: str = "v1,",  # Svix's "v1,"; Stripe uses "v1="
    ) -> None: ...

    def verify(
        self,
        headers: Mapping[str, str],
        body: bytes,
    ) -> tuple[str, int]:
        """Return (event_id, timestamp). Raise HmacVerificationError on
        any failure (missing header / stale ts / wrong sig / unparseable
        ts).
        """
```

The error type lives in `mgf.common.exceptions` as a new
`HmacVerificationError(HttpUnauthorized, OperationError)` so consumers
inherit the 401 mapping via PAPER-24's `http_status` resolution.

The verifier is intentionally **transport-agnostic** — it takes a
`Mapping[str, str]` and `bytes`, not a FastAPI `Request`. A thin
adapter `mgf.common.fastapi.webhooks.verify_request(req, verifier)`
lifts a FastAPI request into the verifier's input shape.

**Cost if deferred past v1.0.** Every web consumer of mgf-common
re-implements this. Fix-by-fix divergence is the failure mode (one
consumer notices the timestamp window is too tight, fixes locally;
the other two consumers stay buggy). The "obvious" workaround
(`svix` Python SDK) brings in a 200 KB dep tree; for projects already
running mgf-common's tight closed-box discipline, that's a
non-trivial cost.

**Decision rationale.** Approach A from the filing, with five
clarifications applied during implementation:

1. **Svix-only at v1.** Stripe / GitHub / Slack use related-but-distinct
   shapes (combined headers, hex encoding, alternate prefixes); shipping
   one schema today means one design we can stand behind. Adding presets
   waits for second-consumer signal.
2. **`HmacVerificationError(HttpUnauthorized)` single-inherit** — matches
   existing leaf conventions (`HttpNotFound` etc.). Multi-inherit
   `(HttpUnauthorized, OperationError)` from the filing was rejected;
   simpler MRO; HTTP-01 still gives 401.
3. **Frozen `@dataclass(frozen=True, slots=True)`** for `WebhookHeaderSchema`,
   not the `Protocol` shape from the filing's pseudocode (Protocols
   can't be instantiated).
4. **Force-lowercase header lookup** internally — verifier normalises
   both schema names and input header keys. Caller has one less footgun.
5. **Replay-cache hook deferred** to a future paper. Stripe recommends
   rejecting same-event-id within the replay window as defense beyond
   the timestamp check, but adding a pluggable storage interface is its
   own design space — file when a consumer asks.

**Retrospective.** Approach A landed verbatim with the five
clarifications above. The transport-agnostic core (header `Mapping[str, str]`
+ body `bytes`) tested cleanly without FastAPI, then the request adapter
lifted it without re-implementing the verification logic. PlasmaMapper's
`MockAuthProvider` + `ClerkAuthProvider` deduplicate to one verifier
construction + one `verify(...)` call; ~80 LOC removed across both files.
The HTTP-01 + PAPER-24 composition (HmacVerificationError → HttpUnauthorized
→ 401 via `http_status` resolution) is the canonical example of why the
v0.18 + v0.19 architectural batches paid for themselves — adding a new
401-shaped exception touches no middleware, no `status_map`, no consumer
config. 25 tests cover every failure mode plus the FastAPI integration
path; constant-time comparison is exercised structurally (multi-version
sig key rotation tests verify the loop, not the timing). Stripe / GitHub /
Slack presets remain follow-up paper candidates; same with the replay-cache
hook.

---

#### <a id="PAPER-27"></a>`PAPER-27` — `mgf.common.fastapi.security.IpAllowlist` dependency

| Field | Value |
|---|---|
| Status | ✅ SHIPPED |
| Severity | 🟢 |
| Submitter | PlasmaMapper (commit `45dbb25`) |
| Submitted | 2026-05-08 |
| Decision | accept (Approach A — coupled-to-FastAPI dependency form; CIDR list at construction time; explicit `trust_proxy=False` default; loopback CIDRs as default `cidrs=`; misconfig fails fast at construction) |
| Decided on | 2026-05-08 |
| Scheduled for | v0.27.0 |
| Shipped in | v0.27.0 — new module `mgf.common.fastapi.security` with two public names: `IpAllowlist` (FastAPI dependency; CIDR allowlist; IPv4 + IPv6 dual-stack; cross-version deliberately rejected; raises `HttpForbidden` on miss; HTTP-01 + PAPER-24 give 401 401 — actually 403 — automatically), and `LOOPBACK_CIDRS` (the default `cidrs=` value, `("127.0.0.0/8", "::1/128")` so tests work out-of-the-box). The `trust_proxy: bool = False` default concentrates the canonical proxy-trust footgun: honouring `X-Forwarded-For` requires explicit opt-in once a forwarded-headers middleware is in place. Misconfigured CIDRs fail at construction time, not first request. Migration guide in [`docs/cutover/v0.27.0.md`](docs/cutover/v0.27.0.md#paper-27--ipallowlist). 16 tests including IPv4/IPv6 match, cross-version deny, proxy-trust on/off, missing client IP, unparseable IP, multi-XFF chain, exception mapping through `ExceptionTranslationMiddleware`. |

**Concern.** PlasmaMapper's Phase-3 admin surface
(`POST /admin/trackers`) gates on a CIDR allowlist while SaaS auth is
not yet wired. The dependency at
`src/plasmamapper/api/security/admin_ip.py` does the obvious things —
parse CIDR strings, compare `request.client.host`, version-match v4 vs
v6, log + 403 on mismatch. This pattern recurs in every web app with
ops-only / staging-only endpoints; promoting it to mgf-common saves ~80
LOC per consumer and concentrates the proxy-trust gotcha in one place.

**Why it matters.** The proxy-trust footgun is real — the dependency
must explicitly NOT honour `X-Forwarded-For` unless the consumer has
configured a forwarded-headers middleware. A library-side helper can
make that decision once with a clearly-named knob (`trust_proxy=False`
default) instead of every consumer re-rediscovering. Plus, IPv6 dual-
stack matching and graceful degradation on a missing `request.client`
are easy to get wrong in single-paragraph re-implementations.

**Suggested shape.**

```python
# mgf.common.fastapi.security
class IpAllowlist:
    def __init__(
        self,
        cidrs: Iterable[str],
        *,
        trust_proxy: bool = False,
    ) -> None: ...

    def __call__(self, request: Request) -> None:
        """FastAPI dependency form. Raises HttpForbidden on miss."""
```

Defaults: loopback (127.0.0.1/32 + ::1/128) so tests work
out-of-the-box; `trust_proxy=False` so the deploy hardening is the
explicit decision.

**Cost if deferred past v1.0.** Low — every consumer can ship their
own. The ergonomic argument is "it's the same code in every consumer"
and the security argument is "concentrate the proxy-trust footgun."
Both real but not blocker-shaped.

**Decision rationale.** Approach A from the filing — FastAPI-coupled
dependency form (single class, `__call__(request)`); CIDR list at
construction time (not per-request settings reload); `trust_proxy=False`
default with explicit opt-in for XFF reading. Pure-logic-core +
framework-adapter split (the alternate shape) was rejected for v0.27 —
no second framework consumer has signalled the need; Django/Flask
parallel-port primitives can be filed as follow-up papers.

**Retrospective.** Approach A landed cleanly. The `Request` import is
intentionally NOT inside `TYPE_CHECKING` (with `# noqa: TC002`) because
FastAPI evaluates dependency annotations at registration time via
`get_type_hints` — moving it would crash dep wiring at app-startup
time. PlasmaMapper's `src/plasmamapper/api/security/admin_ip.py` (~70
LOC including the `lru_cache` parsing, IPv4/v6 version-match,
graceful no-client-IP degradation, and `HttpForbidden` raise)
deletes; the consumer instantiates `IpAllowlist(cidrs=settings...)`
once at app-startup time and references via `Depends(gate)`. The
HTTP-01 + PAPER-24 composition gives the 403 response shape
automatically. Runtime-mutable allowlists (settings reload) and
non-XFF forwarded-header schemes (`Forwarded:`, `X-Real-IP`) are
documented as v0.28+ candidates.

---

#### <a id="PAPER-28"></a>`PAPER-28` — `mgf.common.fastapi.testing.run_test_app` — context-manager test API server

| Field | Value |
|---|---|
| Status | ✅ SHIPPED |
| Severity | 🟢 |
| Submitter | PlasmaMapper (commit `45dbb25`) |
| Submitted | 2026-05-08 |
| Decision | accept (Approach A — async context manager; standalone uvicorn wrapper; surfaces startup exceptions; clean shutdown with CancelledError suppression) |
| Decided on | 2026-05-08 |
| Scheduled for | v0.27.0 |
| Shipped in | v0.27.0 — new module `mgf.common.fastapi.testing` with one public name: `run_test_app(app, *, port=None, host="127.0.0.1", log_level="warning", startup_timeout_seconds=5.0) -> AsyncIterator[str]`. Async context manager — yields the base URL (`http://<host>:<port>`); tears down cleanly on context exit including under exception. `port=None` picks a free ephemeral port. Surfaces server-startup exceptions immediately instead of timing out silently (the load-bearing test-DX fix). Raises `TimeoutError` if uvicorn doesn't reach `server.started` within `startup_timeout_seconds`. `CancelledError` from the serve task is suppressed on clean shutdown. The `[fastapi-test]` extra grew `uvicorn>=0.30`. Migration guide in [`docs/cutover/v0.27.0.md`](docs/cutover/v0.27.0.md#paper-28--run_test_app). 8 tests including reachable-base-URL, route-serves-correctly, parallel-free-ports, explicit-port, clean-shutdown-releases-port, exception-still-shuts-down, startup-timeout. |

**Concern.** Per ADR-013 (UX testing strategy), PlasmaMapper's
Playwright golden-paths run against a real FastAPI app on a free
port, not against MSW mocks — closer to production fidelity, lets
the OpenAPI contract bite, plays nicely with cross-context
propagation tests (operator + driver in parallel browser
contexts). The implementation at
`tests/ux/fixtures/api_server.py` is ~120 LOC of `uvicorn.Server`
+ `asyncio.create_task` + free-port discovery + start/stop
choreography. The pattern is **identical** for any FastAPI app:
build the app, pick a port, await `server.started`, yield URL,
shut down on context exit. A library-side helper would let a
consumer write:

```python
async with mgf.common.fastapi.testing.run_test_app(create_app(settings)) as url:
    ...
```

instead of re-implementing the dance.

**Why it matters.** The free-port-discovery + uvicorn lifecycle is
fiddly enough that small bugs (race on `server.started`,
`asyncio.CancelledError` not suppressed on stop, port grabbed but
leaked on test failure) recur per consumer. Centralising it gives
all web consumers a tested baseline for Playwright integration —
which ADR-013 documents as the canonical UX testing pattern across
the mgf-common ecosystem.

**Suggested shape.**

```python
# mgf.common.fastapi.testing
@asynccontextmanager
async def run_test_app(
    app: FastAPI,
    *,
    port: int | None = None,    # None = pick free port
    host: str = "127.0.0.1",
    log_level: str = "warning",
    startup_timeout_seconds: float = 5.0,
) -> AsyncIterator[str]:
    """Start uvicorn for ``app`` on a free port; yield the base URL.

    Tears down cleanly on context exit, including on test exception.
    """
```

The seeding part is consumer-specific (per-app fixture data) and
stays in consumer code — only the server lifecycle moves into
mgf-common.

**Cost if deferred past v1.0.** Low. Each consumer can ship their
own ~120 LOC. The argument is convergence + fewer
"why does my Playwright run flake" investigations.

**Decision rationale.** Approach A — async context manager wrapping
uvicorn directly (not wrapping `mgf.common.process.Service`). Service
launches subprocesses; uvicorn-as-library runs in-process by design,
which is what tests need (faster, simpler teardown, shared module
state with the test code). Standalone wrapper keeps the dependency
graph minimal — pulls only `uvicorn` (already a transitive dep of
`fastapi[standard]` for any consumer running uvicorn in production).

**Retrospective.** Approach A landed verbatim with one DX-load-bearing
addition: the helper polls for `server.started` and ALSO checks if the
serve task has crashed (port collision, lifespan handler failure)
during the wait window. Without the crash check, a startup failure
manifests as a silent `TimeoutError` after the full timeout — nearly
impossible to debug on CI flakes. With the check, the underlying
exception surfaces immediately. The exception-still-shuts-down test
is the load-bearing safety property: if a test inside the context
raises, the `finally` branch still drains the serve task, so the bound
port is reusable immediately. PlasmaMapper's `tests/ux/fixtures/api_server.py`
(~120 LOC) deletes; the seed step (test-DB-specific) stays consumer-
side. Sync-context support and multi-app orchestration are documented
as v0.28+ candidates.

---

#### <a id="PAPER-29"></a>`PAPER-29` — `mgf.common.fastapi.security.BearerSession` — auth-provider-backed session FastAPI dependency

| Field | Value |
|---|---|
| Status | ✅ SHIPPED |
| Severity | 🟡 |
| Submitter | PlasmaMapper (Phase 3 Task 6) |
| Submitted | 2026-05-08 |
| Decision | accept (suggested shape verbatim, modulo factory naming + Protocol covariance) |
| Decided on | 2026-05-10 |
| Scheduled for | mgf-fastapi 0.3.0 |
| Shipped in | `mgf.fastapi.security.bearer_session(verifier_attr, *, cookie_name=None)` factory + `mgf.fastapi.security.AuthVerifier[P]` Protocol — see `mgf-fastapi` repo (mgf-common is in its v0.32+ stabilization window so the addition lands in the sibling, exercising the federation's independent-semver decision). Tests: `tests/unit/test_security.py::TestBearerSession*` (14 new tests covering happy path, failure modes, cookie fallback, custom `verifier_attr`). Pin bumped to `mgf-common>=0.31,<0.32`. Cutover guide: [`mgf-fastapi/docs/cutover/v0.3.0.md`](https://codeberg.org/magogi-admin/mgf-fastapi/src/branch/main/docs/cutover/v0.3.0.md). |

**Retrospective.** Shipped close to the suggested shape: a factory
returning a FastAPI dependency, an `AuthVerifier` Protocol (covariant
in the principal type so consumer subclassing of principals
composes), case-insensitive scheme matching per RFC 7235, optional
cookie fallback for browser clients with header precedence. The
factory keeps the principal type as `Any` in its return annotation —
Python typing can't carry the principal across the `Depends(...)`
boundary — and consumers recover precise typing via
`Annotated[MyPrincipal, Depends(bearer_session())]`, which is the
idiomatic FastAPI pattern anyway.

Two design refinements at implementation time:

1. Verifier-missing returns `HttpUnauthorized("authentication not
   configured")` to the caller (not a 500), with the deployment
   misconfiguration loud in `LOG.error`. Surfacing "your verifier is
   missing" to an unauthenticated caller would leak deployment
   detail; surfacing it to ops is the right move.

2. Verifier raising `HttpUnauthorized` directly passes through
   unchanged (preserving the verifier's own message), but any other
   exception gets translated to `HttpUnauthorized("invalid or
   expired session")` with the original cause chained via
   `__cause__`. This gives consumers a clean two-tier failure path:
   "I have something specific to tell the client" vs. "translate
   anything I throw to a 401".

The "Bonus surface" mentioned in the filing (`AuthProvider` Protocol
under `mgf.common.providers.auth` combining `verify_session` +
`verify_webhook`) stays a future consideration — both halves shipped
independently (PAPER-26 webhooks v0.26.0, PAPER-29 sessions v0.3.0
of mgf-fastapi); whether to bind them into a single mgf-common
Protocol is a separate filing if and when a third use case
surfaces.

**Concern.** Every authenticated FastAPI route in a SaaS app needs the
same dependency: read the ``Authorization: Bearer <token>`` header,
strip the prefix, hand the bare token to an auth-provider abstraction
that returns the typed session principal, and translate any failure
into ``HttpUnauthorized``. PlasmaMapper ships this in
``src/plasmamapper/api/security/session.py`` (~30 LOC + a small test).
Every other web consumer of mgf-common will reproduce the same shape;
the only variable is the ``AuthProvider`` Protocol shape — itself a
candidate for promotion (see "Bonus surface" below).

**Why it matters.** "Bearer header → typed session" is the gate every
authenticated route runs through. It's small per consumer, but the
patterns differ subtly: some consumers honour cookies alongside
bearers, some require a tenant-membership claim alongside the
principal, some return ``None`` for anonymous + others raise.
Codifying a small protocol-friendly default in mgf-common gives
every web consumer the same shape, gives an obvious place to
document the proxy-trust caveat (the same one that bites PAPER-27's
``IpAllowlist``), and concentrates the constant-time-comparison +
header-parsing edges in one tested module.

**Suggested shape.**

```python
# mgf.common.fastapi.security
from typing import Awaitable, Callable, Protocol, TypeVar

P = TypeVar("P")  # consumer's principal type (e.g., SessionPrincipal)

class AuthVerifier(Protocol[P]):
    async def verify_session(self, token: str) -> P: ...

def bearer_session(
    verifier_attr: str = "auth_provider",
    *,
    cookie_name: str | None = None,  # None = bearer-only
) -> Callable[..., Awaitable[P]]:
    """Build a FastAPI dependency that resolves the session principal.

    The returned function reads the ``AuthVerifier`` from
    ``request.app.state.<verifier_attr>``; tests swap providers by
    mutating ``app.state``. ``cookie_name`` (when set) makes the
    dep also accept a session cookie for browser clients.
    """
```

Failure path raises ``HttpUnauthorized`` (already in
``mgf.common.exceptions``) so the FastAPI middleware translates to
401 via the PAPER-24 ``http_status`` resolution path — no per-consumer
``status_map`` extension required.

**Bonus surface — lower-leverage, mention only.** PlasmaMapper's
``AuthProvider`` Protocol (per ADR-004) has two methods
(``verify_session`` + ``verify_webhook``) lining up cleanly with
PAPER-26's webhook verifier. If both PAPER-26 and PAPER-29 land,
codifying ``AuthProvider`` as a small Protocol under
``mgf.common.providers.auth`` (no concrete; consumers implement
their own Clerk/Authentik/etc.) becomes attractive. Filing as a
future consideration; not blocking PAPER-29.

**Cost if deferred past v1.0.** Low. Each web consumer ships their
own ~30-LOC dep. Argument: convergence + canonical place for the
proxy-trust caveat + canonical place for the cookie-vs-bearer
choice + tested constant-time-compare on token equality.

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

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

---

#### <a id="PAPER-31"></a>`PAPER-31` — Conformance-audit pattern: separate "N/A" from "🚫 declined-with-rationale"

| Field | Value |
|---|---|
| Status | ✅ SHIPPED |
| Severity | 🟡 High (audit clarity) |
| Submitter | PlasmaMapper (commit `<this commit>`) |
| Submitted | 2026-05-15 |
| Decision | accept (Approach A — sibling principle doc with full six-flag taxonomy + decision tree) |
| Decided on | 2026-05-19 |
| Scheduled for | shipped in-tree (v1.0 doc bundle) |
| Shipped in | `docs/standards/principles/audit-outcomes.md` + cross-link from `principles/non-adoption-is-valid.md` + README index update |

**Concern.** Doing a per-rule conformance audit against the
16 standards produces three legitimate non-✅ outcomes that today
share one flag:

1. **N/A** — the rule's scope doesn't apply (e.g., GUI rules for a
   backend-only service, PyPI-release rules for a consumer-app).
   The consumer never *could* conform; the rule just isn't theirs.
2. **🚫 declined-with-rationale** per `principles/non-adoption-is-valid.md`
   — the rule *could* apply but the consumer consciously declines
   (e.g., the web carve-out's "config from env-only, no `--config`
   flag" against CF-09). The consumer's choice carries a documented
   rationale and is a real decision.
3. **⏳ no-surface-yet** — the rule applies but there's nothing to
   audit because the feature hasn't shipped (e.g., AP-04
   deprecation cycle when no deprecations exist).

A first-pass audit reaches for 🚫 for all three because the
`non-adoption-is-valid.md` principle is the only "I'm not
following this and that's fine" framing in the standards corpus.
That conflation hides signal: a 🚫 against CF-09 (web carve-out)
is a real design choice the next consumer should know about; a
🚫 against EH-06 (GUI worker rule for a backend service) is just
"wrong project shape." Surfacing both as 🚫 means a reviewer can't
tell which 🚫 rows need a rationale doc and which don't.

**Why it matters.** Standards-conformance audits are exactly the
exercise the standards corpus is meant to support — and the
audit's signal is most useful when it distinguishes "design
choice" from "scope mismatch." The PlasmaMapper audit pass
filed today (`docs/inprogress/MGF_STANDARDS_CONFORMANCE.md`)
hit this immediately: the first agent run flagged 9 rows as 🚫
which on review-pass split into 6 N/A + 3 actual declines. That's
a 2:1 noise ratio in the "declined" signal — meaningful enough
to warrant naming the distinction upstream.

**Concrete evidence.**

- `principles/non-adoption-is-valid.md` is the only "I'm not
  following this rule" framing in `docs/standards/`. The README's
  conformance-level table doesn't enumerate other non-✅ outcomes.
- The PlasmaMapper audit (`docs/inprogress/MGF_STANDARDS_CONFORMANCE.md`
  §2) shows the three categories side-by-side: e.g., **EH-06**
  (GUI workers) = N/A vs **AP-12** (CHANGELOG for PyPI) = 🚫
  declined-with-rationale vs **CF-11** (field-rename alias) = ⏳
  no-surface-yet. Each row's text makes the category clear; the
  status flag alone does not.
- The non-adoption-is-valid principle's worked examples
  (LOG-01, BOOTSTRAP-01, "small consumer" scope discipline) are
  all category-2 (conscious decline) cases — none of them are
  category-1 (rule doesn't apply at all) or category-3 (no
  surface yet). The principle's name is accurate but the framing
  doesn't acknowledge the other two outcomes.

**Suggested shape.**

**Approach A (lightweight, doc-only):** add a short addition to
`principles/non-adoption-is-valid.md` (or a new sibling
`principles/audit-outcomes.md`) that names the three categories
explicitly + recommends three different flags for audit work:

```
- ✅ conformant
- ❌ violation
- ⚪ partial
- 🚫 declined-with-rationale       — rule applies; we choose not to follow it
- N/A                                — rule scope does not apply to this project
- ⏳ no-surface-yet                 — rule applies but the feature it gates hasn't shipped
```

**Approach B (opinionated, slightly heavier):** ship an
`audit-template.md` (or a `mgf-common audit-template` CLI
sub-command) that emits the rule list with placeholder rows in
each of the four non-✅ flags pre-filled. A consumer fills the
status + evidence; the standardised vocabulary stays consistent
across consumers. This is a generalisation of the "living
priority tracker" shape PlasmaMapper uses across its 5+ trackers.

**Recommendation:** Approach A. The standards corpus is already
prose-heavy; adding a flag taxonomy doesn't need tooling.
Approach B is a 💭 follow-up if multiple consumers ship audits.

**Decision rationale.** Accept Approach A as recommended. The
six-flag vocabulary (`✅ / ❌ / ⚪ / 🚫 / N/A / ⏳`) generalises
the three sub-categories the consumer flagged plus the two
straightforward outcomes (`✅` and `❌`) and the partial-match
case (`⚪`). Promoted to a standalone principle file rather than
an addendum to `non-adoption-is-valid.md` so future contributors
searching for "audit taxonomy" find a self-contained doc; the two
files cross-reference each other.

**Retrospective.** Shipped as `principles/audit-outcomes.md` (a
sibling to `non-adoption-is-valid.md`) with a six-flag table, a
decision-tree walk, and the three worked examples named in the
submitter's evidence (EH-06 = N/A, AP-12 = 🚫, CF-11 = ⏳).
Cross-linked from `non-adoption-is-valid.md` "Cross-references"
and added to `docs/standards/README.md` under "Conformance levels
for projects" alongside the existing non-adoption framing. The
suggested vocabulary (Approach A's bullet list) ships verbatim;
Approach B (CLI sub-command) stays parked until a second consumer
ships an audit pass — `mgf-common audit-template` would be the
shape if/when it lands.

---

#### <a id="PAPER-32"></a>`PAPER-32` — Web-shape carve-outs should cross-reference from each affected rule

| Field | Value |
|---|---|
| Status | ✅ SHIPPED |
| Severity | 🟡 High (consumer-time waste at audit) |
| Submitter | PlasmaMapper (commit `<this commit>`) |
| Submitted | 2026-05-15 |
| Decision | accept (Approach A — inline `**Web-shape note.**` paragraphs at the affected rule's vicinity in each topic doc) |
| Decided on | 2026-05-19 |
| Scheduled for | shipped in-tree (v1.0 doc bundle) |
| Shipped in | `CONFIGURATION.md` §6 (CF-09), `LOGGING.md` §8 (LG-06), `LOGGING.md` §10 (LG-10), `API_DESIGN.md` front matter (AP-10/11/12/15 consumer-application carve-out) |

**Concern.** `web/README.md` carries a narrative wrapper that names
specific carve-outs for FastAPI / Django / Flask consumers — e.g.,
CF-09's `--config` flag and OS-aware default path is declined
because web services consume config from env, not CLI flags;
LG-10's JournalHandler is declined because container orchestration
captures stdout; LG-06's file-rotation branch is replaced by
stdout-only. These are real, load-bearing decisions.

**The problem is one-way discoverability.** A consumer auditing
CF-09 reads the rule text in `CONFIGURATION.md`, finds nothing
about a web-shape exemption, and has to either:
- read `web/README.md` cover-to-cover hoping it covers CF-09, or
- file the rule as ❌ and rediscover the carve-out in PR review.

The standards corpus's master index (`README.md`) does point at
`web/README.md` near the top, but the per-rule reading path
doesn't reach the carve-out. Consumers don't read the standards
corpus end-to-end before each audit — they search for the rule
they're checking.

**Why it matters.** Every web-shape consumer hits this. The
PlasmaMapper audit's review pass had to manually walk
`web/README.md` to confirm which 🚫 rows had a real upstream
carve-out vs. which were just "rule doesn't apply" — the rule
text itself didn't say.

**Concrete evidence.**

- `CONFIGURATION.md` CF-09 (`--config` flag): no inline reference
  to the web carve-out that declines it for services.
- `LOGGING.md` LG-10 (JournalHandler): no inline reference to the
  web carve-out that declines it for container orchestration.
- `LOGGING.md` LG-06 (rotating file → aggregator): the rule text
  presents this as "logs reach an aggregator (file / stdout /
  remote)" but the web carve-out has a stronger preference for
  stdout-only that doesn't surface in the rule.
- `web/README.md` references these carve-outs collectively but
  doesn't make it easy to round-trip from a rule ID to "is there
  a web-shape note?"

**Suggested shape.**

Add a `**Web-shape note.**` paragraph inline at each affected
rule pointing at `web/README.md` §<anchor>. Example for CF-09:

```markdown
**CF-09 — `--config` flag + OS-aware default path**

[…existing rule text…]

**Web-shape note.** Web services consume config from environment
variables + an optional `.env` file at the project root; they do
NOT take a `--config` CLI flag. Declined per
[`web/README.md` §Framework-native config](web/README.md#framework-native-config).
```

This is one paragraph per affected rule (CF-09, LG-06, LG-10,
maybe AP-12 for non-PyPI consumer apps). The maintainer carries
the cross-link burden so every consumer doesn't.

**Decision rationale.** Accept Approach A as suggested. The
inline-at-rule shape preserves the round-trip from rule ID to
carve-out without forcing a cover-to-cover read of
`web/README.md`. Placed each note in the rule's expansion-§
neighbourhood (not in the rules box table, where row-cell
crowding would harm readability): CF-09 → §6 (CLI flag overrides),
LG-06 → §8 (Sinks summary), LG-10 → §10 (Headless services).
AP-12 was generalised to a scope-clarifying paragraph in
API_DESIGN.md's front matter covering all four publish-discipline
rules (AP-10 / AP-11 / AP-12 / AP-15) — a single front-matter
note serves better than four inline duplications because the
consumer-app carve-out applies en bloc.

**Retrospective.** Four edits total. Each `**Web-shape note.**`
paragraph cross-links to `web/README.md` and (where applicable)
to the relevant topic-doc carve-out section. Cross-references
audited; no broken anchors. The AP-12 carve-out's worked example
lives in `principles/audit-outcomes.md` (the PAPER-31
companion).

---

#### <a id="PAPER-33"></a>`PAPER-33` — `BaseAppSettings.version: int = 1` auto-provision is invisible to CF-03 audits

| Field | Value |
|---|---|
| Status | ✅ SHIPPED |
| Severity | 🟢 Nice-to-have (one-line doc cross-link) |
| Submitter | PlasmaMapper (commit `<this commit>`) |
| Submitted | 2026-05-15 |
| Decision | accept (generalised suggested shape — one table covering CF-02 / CF-03 / CF-06 / CF-08 rather than per-rule callouts) |
| Decided on | 2026-05-19 |
| Scheduled for | shipped in-tree (v1.0 doc bundle) |
| Shipped in | `CONFIGURATION.md` §0b "Auto-provisions you inherit from `BaseAppSettings`" |

**Concern.** `CONFIGURATION.md` rule CF-03 says "Settings schema
MUST carry a `version: int` field with a documented migration
chain." A consumer using `BaseAppSettings` already conforms —
`mgf_common/src/mgf/common/config/_settings.py:121` declares
`version: int = 1` automatically — but the rule text and the
mgf-common consumer-facing docs don't say so. A consumer auditing
CF-03 has to either grep mgf-common source or file CF-03 as
"⏳ unclear" until they check.

The PlasmaMapper audit's first pass hit this exactly: the
agent flagged CF-03 as ⏳ unclear ("PlasmaSettings has no version
field visible in code"). The review pass had to grep
`mgf_common/src/mgf/common/config/_settings.py` to confirm
`BaseAppSettings.version: int = 1` is inherited.

**Why it matters.** Every consumer of `BaseAppSettings` (which is
every L1+ project per the standards) hits this. The rule is
genuinely conformant-for-free; making that invisible costs each
consumer one grep at audit time.

**Concrete evidence.**

- `mgf_common/src/mgf/common/config/_settings.py:121`:
  `version: int = 1` declared on `BaseAppSettings`.
- `CONFIGURATION.md` CF-03 (the rule): no cross-link to the
  auto-provision.
- `docs/recipes/` (or wherever the BaseAppSettings recipe lives):
  no callout that subclassing BaseAppSettings gets CF-03 for
  free.

**Suggested shape.**

One line added to CF-03's rule text:

```markdown
**Auto-provision note.** Subclassing
`mgf.common.config.BaseAppSettings` provides `version: int = 1`
automatically — your `Settings` class inherits CF-03 conformance
without further work. Override the default + drive the migration
chain when the schema evolves past v1.
```

Same pattern would also help CF-06 (`extra="forbid"` is
auto-provisioned via the base's `model_config`), CF-02 (the
precedence layering is `pydantic-settings`-mediated and
inherited), and CF-08 (the "loaded once at entry point" pattern
is what `mgf.common.bootstrap()` implements). One audit-pass-
visible callout per rule.

**Decision rationale.** Accept the generalised shape — one
`## 0b` section in `CONFIGURATION.md` that tables CF-02 / CF-03 /
CF-06 / CF-08 in a single audit-discoverable place, with
per-rule `src/...` citations. Per-rule callouts in the rules box
itself would crowd the table; a dedicated §0b section parallels
§0a (framework-native carve-out) and is the natural neighbour to
the per-rule scoping commentary that already opens the topic
doc.

**Retrospective.** Shipped as
`CONFIGURATION.md §0b "Auto-provisions you inherit from
BaseAppSettings"`. Single table of four rules (CF-02 / CF-03 /
CF-06 / CF-08) with file:line citations to
`src/mgf/common/config/_settings.py` (settings_customise_sources
+ `version: int = 1` + `extra="forbid"`) and
`src/mgf/common/_lifecycle.py:bootstrap` / `_loader.py:load_settings`
for CF-08. Override-discipline note ("a subclass MAY override —
that's the explicit decision and the auto-provision no longer
applies") plus an inverse list of what is NOT auto-provisioned
(CF-01 / CF-04 / CF-05 / CF-07 / CF-09 / CF-10 / CF-11 / CF-12) so
the audit-side reader doesn't conflate the section's coverage.

---

#### <a id="PAPER-34"></a>`PAPER-34` — `scan-deps` recipe candidate for `COMMON_COMPONENTS.md`

| Field | Value |
|---|---|
| Status | ✅ SHIPPED |
| Severity | 🟢 Nice-to-have (lift pattern) |
| Submitter | PlasmaMapper (commit `<this commit>`) |
| Submitted | 2026-05-15 |
| Decision | accept (Approach as named — new recipe under `docs/recipes/scan-deps.md` with PlasmaMapper SH-25 as reference implementation) |
| Decided on | 2026-05-19 |
| Scheduled for | shipped in-tree (v1.0 doc bundle) |
| Shipped in | `docs/recipes/scan-deps.md` + index update in `docs/recipes/README.md` (both domain index and A-Z list) |

**Concern.** `COMMON_COMPONENTS.md` catalogues the cornerstone
primitives — bootstrap, observability, vault, exceptions, settings,
fastapi adapters. It does NOT catalogue a recommended **dependency-
vulnerability-scanning recipe** (pip-audit + npm/pnpm audit) even
though every L1+ consumer needs one, and the CI shape is small +
near-identical across consumers.

PlasmaMapper just shipped SH-25 (commit `849d867`,
docs/inprogress/SECURITY_HARDENING.md §SH-25) with:

- A `pip-audit` CI step running via `uv tool run --from pip-audit`
  against `uv export --format requirements-txt --no-dev
  --no-hashes --no-sources` (CI-shape export).
- A `.pip-audit-ignore` file shape (one PYSEC-/GHSA-ID per line,
  date + rationale comments) for triaging accepted advisories.
- A `pnpm audit --audit-level=high` step for the frontend.

The gate caught two real CVEs on the very PR that introduced it
(urllib3 2.6.3 → 2.7.0, devalue 5.8.0 → 5.8.1), which is the
proof-of-value the audit standard explicitly asks for.

**Why it matters.** Every consumer reinvents this. The
PlasmaMapper SECURITY_HARDENING audit promoted R-17 (P2,
dependency-vulnerability gate) to SH-25 (P1) precisely because
the solo-dev / hand-managed shape is where this fails first. A
recipe in `COMMON_COMPONENTS.md` (or a sibling
`docs/recipes/scan-deps.md`) would let the next consumer skip
the design step and ship the CI gate directly.

**Concrete evidence.**

- `.woodpecker.yml` (PlasmaMapper, post-`849d867`): `pip-audit`
  step (lines for the new step) + `frontend-audit` step.
- `.pip-audit-ignore` (PlasmaMapper, post-`849d867`): the
  triage-file shape.
- `docs/inprogress/SECURITY_HARDENING.md` §SH-25 closure entry:
  the "caught 2 real CVEs on the very PR that introduced the
  gate" retrospective.

**Suggested shape.**

A new section under `docs/recipes/` or an entry in
`COMMON_COMPONENTS.md` §"CI gates" naming `scan-deps` as a
recommended recipe, with:

```
- Tool choice: pip-audit (Python) + pnpm/npm audit (frontend).
- Run shape: against the lock export, not the active env
  (handles the editable-sibling case + matches CI's
  --no-sources install path).
- Triage file: <project>/.pip-audit-ignore — one ID per line,
  inline date + rationale comments.
- Severity gate: --audit-level=high for the frontend (lower
  thresholds drown in transitive dev-tool noise); --strict for
  pip-audit (any CVE fails, ignore-file is the escape).
- The ignore-file becomes the audit trail; empty file = strict
  mode.
```

If the recipe ships, PlasmaMapper's SH-25 closure entry should
be cross-referenced as the reference implementation (per
PAPER-06's spirit).

**Decision rationale.** Accept as a recipe (not as a
COMMON_COMPONENTS.md catalogue entry). `COMMON_COMPONENTS.md`
catalogues mgf-common's own primitives; `scan-deps` is a CI gate
the consumer wires from upstream tooling (`pip-audit` / `pnpm
audit`), not an mgf-common API. The recipe directory is the right
host. The recipe cross-references PlasmaMapper SH-25 (commit
`849d867`) as the reference implementation per PAPER-06's spirit
(public consumer commits, not private file:line citations).

**Retrospective.** Shipped as `docs/recipes/scan-deps.md` with
six sections: when to use; the two-tool shape; lock-export run
shape (with full Woodpecker YAML fragment); the
`.pip-audit-ignore` triage file with sunset dates; frontend
audit (`--audit-level=high`); CI placement (pre-merge, parallel
with lint/typecheck); per-release advisory walk. Indexed under a
new "CI gates" section in `docs/recipes/README.md` AND in the A-Z
list. The recipe's framing — "the ignore-file IS the audit
trail" — generalises beyond the specific tools.

---

#### <a id="PAPER-35"></a>`PAPER-35` — Federation-aligned releases lack an aggregated consumer cutover guide

| Field | Value |
|---|---|
| Status | ✅ SHIPPED |
| Severity | 🟢 Nice-to-have |
| Submitter | PlasmaMapper (commit `<this commit>`) |
| Submitted | 2026-05-18 |
| Decision | accept (Approach A — single aggregated cutover, owned by mgf-common; retroactive v0.34 backfill doubles as the canonical template) |
| Decided on | 2026-05-19 |
| Scheduled for | shipped in-tree (v1.0 doc bundle) |
| Shipped in | `docs/cutover/federation-v0.34.0.md` (retroactive backfill + template) + `docs/cutover/README.md` "Naming + numbering" update naming the federation-aggregate convention |

**Concern.** mgf-common 0.34.0 is a "federation-alignment" release —
the headline change is the v2.6 + v2.7 standards shape, and the
companion sibling releases (mgf-fastapi 0.4.0, mgf-sqlalchemy 0.2.0,
mgf-alembic 0.3.0) carry the same alignment pin-walked into each
sibling's own minor. A consumer that depends on N federation packages
has to read **N CHANGELOGs in sequence** to learn what each pin walk
actually requires of them — there is no single "if you're a consumer
of the federation, here's the coordinated v0.34 pin set + the deltas
that affect you" cutover doc.

The mgf-common 0.34.0 release notes mention the sibling pin shift
("the sibling-pin update ships in each sibling's next release") but
the message is forward-looking; the aggregate is only knowable AFTER
the siblings ship their cycle. By design DOC-05 only requires a
`docs/cutover/v<X.Y.Z>.md` for breaking minors — 0.34.0 was internal-
only, so no cutover was written.

**Why it matters.** Every consumer that uses ≥2 federation siblings
hits this. The friction scales with sibling count: a 1-sibling
consumer reads 2 CHANGELOGs; a 4-sibling consumer (PlasmaMapper's
shape — mgf-common + mgf-fastapi + mgf-sqlalchemy + mgf-alembic)
reads 4 sibling CHANGELOGs + the mgf-common one, and has to walk
4 separate `pyproject.toml` pin lines forward in lockstep. The
4 pin-walks are themselves trivial; the cost is the *triage* —
"which of these is breaking, which is docs-only, which adds new
behaviour I want to opt into?". An aggregated guide collapses
that triage to a single read.

**Concrete evidence.**

PlasmaMapper, 2026-05-18, walking through the v0.34 alignment:

- `pyproject.toml`: 4 pin lines walked
  (`mgf-common>=0.33,<0.34 → >=0.34,<0.35`,
   `mgf-fastapi[sqlalchemy]>=0.3,<0.4 → >=0.4,<0.5`,
   `mgf-sqlalchemy>=0.1,<0.2 → >=0.2,<0.3`,
   `mgf-alembic>=0.1,<0.2 → >=0.3,<0.4`).
- 4 sibling CHANGELOG reads needed to classify each:
  - mgf-common 0.34.0 — internal-only (perf gates + standards docs).
  - mgf-fastapi 0.4.0 — docs-only (SECURITY.md + docs/claude/CLAUDE.md
    + docs/standards/README.md stub).
  - mgf-sqlalchemy 0.2.0 — docs-only (same shape).
  - mgf-alembic 0.3.0 — two new OPTIONAL kwargs (`is_async`,
    `transaction_per_migration`); RA-01 raises on running-loop.
- 4 separate commits landed in `PlasmaMapper@<this branch>` (one per
  pin walk) to keep bisection clean. Useful in retrospect; the
  triage cost was front-loaded.

**Suggested shape.**

When a federation-alignment cycle ships (every time mgf-common
crosses a minor that triggers `>=X,<Y` pin walks in 2+ siblings),
also ship a **single coordinated cutover** at one of these paths:

```
mgf-common/docs/cutover/federation-v<X.Y>.md   # OR
mgf-common/docs/release/federation-alignment-v<X.Y>.md
```

Suggested table-shape:

| Package | Pin (was) | Pin (now) | Consumer code-change required? |
|---|---|---|---|
| mgf-common | `>=0.33,<0.34` | `>=0.34,<0.35` | No — internal-only |
| mgf-fastapi | `>=0.3,<0.4` | `>=0.4,<0.5` | No — docs-only |
| mgf-sqlalchemy | `>=0.1,<0.2` | `>=0.2,<0.3` | No — docs-only |
| mgf-alembic | `>=0.2,<0.3` | `>=0.3,<0.4` | No — defaults preserved; opt in to new kwargs |

Plus a short "what's actually new for consumers" prose block (the
v2.7 DEPLOYMENT.md adoption surface, the perf-gate test additions,
etc.) so the *user-visible* delta is in one place.

This is **Approach A** (single aggregated cutover, owned by the
mgf-common release engineer). Approach B (each sibling's cutover
cross-references the federation-cycle aggregate) is also workable
but doubles the doc surface.

**Decision rationale.** Accept Approach A. Retroactive backfill of
the v0.34 cycle (the consumer's worked example) doubles as the
canonical template — §3 of the backfill doc IS the spec for
subsequent federation-cutover docs. File path:
`docs/cutover/federation-v<X.Y>.md` (keyed off mgf-common's MINOR,
not the PATCH) under the existing `docs/cutover/` directory so the
discovery path stays single.

**Retrospective.** Shipped as
[`docs/cutover/federation-v0.34.0.md`](docs/cutover/federation-v0.34.0.md)
with the four mandated sections: §1 at-a-glance pin walks
(consumer-actionable table), §2 what's actually new for consumers
(narrow opt-in surface — mgf-alembic 0.3.0's new kwargs, standards
v2.6 / v2.7, perf-gate test additions inside mgf-common's own
suite), §3 federation-cutover doc shape (the template for future
cycles), §4 cross-references. `docs/cutover/README.md` updated to
name the `federation-v<X.Y>.md` convention in the "Naming +
numbering" table alongside the existing `v<X.Y.Z>.md` rule. The
retroactive v0.34 backfill is the only federation-cutover doc
that exists today; subsequent alignment cycles (v0.35..v0.38 in
the immediate v1.0 sequence) can author their own using this as
the template.

**Follow-up flagged during shipping.** CHANGELOG[0.34.0]'s "Fixed"
section currently claims PAPER-31..34 were shipped in v0.34 —
they were not; those papers shipped in this v1.0 doc bundle on
2026-05-19. The CHANGELOG entry is from a "papers will be
addressed in this release" anticipation that wasn't borne out.
Worth fixing in a separate cleanup pass; not in scope for this
PAPER-35 closure.

---

#### <a id="PAPER-36"></a>`PAPER-36` — MOVED to `mgf-http`

| Field | Value |
|---|---|
| Status | MOVED |
| Moved on | 2026-05-18 |
| Moved to | [mgf-http/FEEDBACK.md §PAPER-01](https://codeberg.org/magogi-admin/mgf-http/src/branch/main/FEEDBACK.md#paper-01) |
| Why | Sibling-scoped feedback per DOC-13 — sibling-specific papers live in the sibling's own FEEDBACK.md. |

Original title: *Streaming + body cap absent from `AsyncHttpClient.request()`*

The full original entry — concern, evidence, suggested
shape, decision rationale, retrospective — is preserved
at the new location. This stub stays here as a redirect
so the federation-wide paper number remains searchable.

---


#### <a id="PAPER-37"></a>`PAPER-37` — MOVED to `mgf-http`

| Field | Value |
|---|---|
| Status | MOVED |
| Moved on | 2026-05-18 |
| Moved to | [mgf-http/FEEDBACK.md §PAPER-02](https://codeberg.org/magogi-admin/mgf-http/src/branch/main/FEEDBACK.md#paper-02) |
| Why | Sibling-scoped feedback per DOC-13 — sibling-specific papers live in the sibling's own FEEDBACK.md. |

Original title: *SSRF-safe redirect-walk recipe missing from `docs/recipes/http.md`*

The full original entry — concern, evidence, suggested
shape, decision rationale, retrospective — is preserved
at the new location. This stub stays here as a redirect
so the federation-wide paper number remains searchable.

---

#### <a id="PAPER-38"></a>`PAPER-38` — Federation-wide README pin-window staleness; mgf-common is the natural template owner

| Field | Value |
|---|---|
| Status | ✅ SHIPPED |
| Severity | 🟢 Nice-to-have |
| Submitter | inthewords (commit [`f190f61`](https://codeberg.org/magogi-admin/inthewords/commit/f190f61)) |
| Submitted | 2026-05-18 |
| Decision | accept Approach A (drop literal version + link to PyPI) |
| Decided on | 2026-05-18 |
| Scheduled for | applied immediately across the six federation siblings |
| Shipped in | six sibling commits: mgf-http [`dac9085`](https://codeberg.org/magogi-admin/mgf-http/commit/dac9085) · mgf-apiprobe [`f3adbb2`](https://codeberg.org/magogi-admin/mgf-apiprobe/commit/f3adbb2) · mgf-alembic [`5410615`](https://codeberg.org/magogi-admin/mgf-alembic/commit/5410615) · mgf-sqlalchemy [`1ec927e`](https://codeberg.org/magogi-admin/mgf-sqlalchemy/commit/1ec927e) · mgf-fastapi [`d14a833`](https://codeberg.org/magogi-admin/mgf-fastapi/commit/d14a833) · mgf-django [`0922fb8`](https://codeberg.org/magogi-admin/mgf-django/commit/0922fb8) |

**Concern.** Multiple sibling READMEs declare a pin window that
disagrees with the actually-published wheel's `pyproject.toml`
metadata. Concretely:

- **mgf-http 0.3.0** `README.md:7` says
  `mgf-common>=0.33,<0.34`; the wheel says `>=0.34,<0.35`.
- **mgf-apiprobe 0.4.0** `README.md:6` says the same stale
  range; the wheel says `>=0.34,<0.35`.

This is a federation-wide template-hygiene issue. mgf-common is
the natural owner since the sibling READMEs likely derive from
a shared cookiecutter / `mgf-website-template`-style template
(per ASPIRE-06's federation discipline).

**Why it matters.** First-pass consumers reading the README
form an incorrect view of the dependency window. The inthewords
sibling-adoption assessment (`docs/MGF_SIBLING_ASSESSMENT.md`)
initially flagged mgf-apiprobe as "would need an mgf-apiprobe
release targeting 0.34 before we can pull it in" based on the
README. A pip dry-run later showed the wheel was already
compatible. Time lost: small but recurring on every catch-up
cycle.

**Concrete evidence.** Both READMEs above, plus the dry-run
output:

```
$ pip install 'mgf-http' --dry-run
Requirement already satisfied: mgf-common<0.35,>=0.34 ... (from mgf-http) (0.34.0)
```

**Suggested shape.** Two options.

**Approach A — kill the explicit version in the README and
link to PyPI.** Replace the "depends on `mgf-common>=X,<Y`" line
with "see [PyPI metadata](...)" so the version is sourced from
the wheel by definition. Lowest-effort and impossible to drift.

**Approach B — template the README from `pyproject.toml`** at
build time. A docs-build step that reads the dep window and
substitutes it into a README placeholder. More machinery but
keeps the README self-contained.

**Recommendation.** A. The README's job is to orient new
readers; PyPI is the source of truth for pin windows, and one
hop to confirm is fine. B is correct but heavy for the value.

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

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

---

#### <a id="PAPER-39"></a>`PAPER-39` — MOVED to `mgf-apiprobe`

| Field | Value |
|---|---|
| Status | MOVED |
| Moved on | 2026-05-18 |
| Moved to | [mgf-apiprobe/FEEDBACK.md §PAPER-01](https://codeberg.org/magogi-admin/mgf-apiprobe/src/branch/main/FEEDBACK.md#paper-01) |
| Why | Sibling-scoped feedback per DOC-13 — sibling-specific papers live in the sibling's own FEEDBACK.md. |

Original title: *No way to thread response data from one probe into another (auth or otherwise)*

The full original entry — concern, evidence, suggested
shape, decision rationale, retrospective — is preserved
at the new location. This stub stays here as a redirect
so the federation-wide paper number remains searchable.

---

#### <a id="PAPER-40"></a>`PAPER-40` — MOVED to `mgf-apiprobe`

| Field | Value |
|---|---|
| Status | MOVED |
| Moved on | 2026-05-18 |
| Moved to | [mgf-apiprobe/FEEDBACK.md §PAPER-02](https://codeberg.org/magogi-admin/mgf-apiprobe/src/branch/main/FEEDBACK.md#paper-02) |
| Why | Sibling-scoped feedback per DOC-13 — sibling-specific papers live in the sibling's own FEEDBACK.md. |

Original title: *`BearerAuth` hardcodes the "Bearer" scheme; DRF and others need "Token" / custom*

The full original entry — concern, evidence, suggested
shape, decision rationale, retrospective — is preserved
at the new location. This stub stays here as a redirect
so the federation-wide paper number remains searchable.

---

#### <a id="PAPER-41"></a>`PAPER-41` — MOVED to `mgf-apiprobe`

| Field | Value |
|---|---|
| Status | MOVED |
| Moved on | 2026-05-18 |
| Moved to | [mgf-apiprobe/FEEDBACK.md §PAPER-03](https://codeberg.org/magogi-admin/mgf-apiprobe/src/branch/main/FEEDBACK.md#paper-03) |
| Why | Sibling-scoped feedback per DOC-13 — sibling-specific papers live in the sibling's own FEEDBACK.md. |

Original title: *Default checks fire on inapplicable responses (e.g., `MissingHsts` on non-HTTPS)*

The full original entry — concern, evidence, suggested
shape, decision rationale, retrospective — is preserved
at the new location. This stub stays here as a redirect
so the federation-wide paper number remains searchable.

---

#### <a id="PAPER-42"></a>`PAPER-42` — MOVED to `mgf-apiprobe`

| Field | Value |
|---|---|
| Status | MOVED |
| Moved on | 2026-05-18 |
| Moved to | [mgf-apiprobe/FEEDBACK.md §PAPER-04](https://codeberg.org/magogi-admin/mgf-apiprobe/src/branch/main/FEEDBACK.md#paper-04) |
| Why | Sibling-scoped feedback per DOC-13 — sibling-specific papers live in the sibling's own FEEDBACK.md. |

Original title: *`Probe.id()` is mandatory for readable reports but not surfaced as such*

The full original entry — concern, evidence, suggested
shape, decision rationale, retrospective — is preserved
at the new location. This stub stays here as a redirect
so the federation-wide paper number remains searchable.

---

#### <a id="PAPER-43"></a>`PAPER-43` — `mgf-common migrate` ships codemod rules for v0.33 AP-12 removals but doesn't register them for cross-version invocation

| Field | Value |
|---|---|
| Status | ✅ SHIPPED |
| Severity | 🟡 High |
| Submitter | inthewords (commit [`754ceda`](https://codeberg.org/magogi-admin/inthewords/commit/754ceda)) |
| Submitted | 2026-05-18 |
| Decision | accept (Approach A — register the existing rules) |
| Decided on | 2026-05-18 |
| Scheduled for | mgf-common next minor |
| Shipped in | commit [`be55ac1`](https://codeberg.org/magogi-admin/mgf-common/commit/be55ac1) |

**Concern.** The v0.33.0 cutover guide (`docs/cutover/v0.33.0.md`)
documents two codemod rules for the AP-12 deprecation removals:
`make_settings_config → settings_config` and `EnvironmentError →
HostEnvironmentError` (gated on `mgf.common.exceptions` import).
It instructs consumers to "run `mgf-common migrate` from the
project root."

The installed `mgf-common==0.33.0` CLI does not actually
register these rules for cross-version invocation. A
`mgf-common migrate 0.32 0.33 . --dry-run` returns:

```
✗ unsupported migration: 0.32 → 0.33
Supported migrations:
  0.1 → 0.3
```

Only the historical `0.1 → 0.3` migration is registered. The
v0.33 codemod rules exist in the codebase per the cutover guide
but aren't wired to the CLI's version-pair dispatcher.

**Why it matters.** A consumer following the cutover guide
literally to handle the AP-12 removals gets the "unsupported
migration" error and falls back to a manual grep + edit pass.
The cutover guide's claim ("the codemod ships in `mgf-common
migrate`") doesn't match what's installable today. For
inthewords specifically this didn't block anything (we
pre-verified by grep that none of the three removed names were
used in our code), but the doc / behaviour mismatch will trip a
consumer who actually has matching call sites.

**Concrete evidence.** Reproducer against installed
`mgf-common==0.33.0`:

```
$ .venv/bin/mgf-common migrate 0.32 0.33 . --dry-run
✗ unsupported migration: 0.32 → 0.33
Supported migrations:
  0.1 → 0.3
```

The v0.33.0 cutover guide bundled in the v0.34 sdist at
`docs/cutover/v0.33.0.md:33` shows the instruction the consumer
follows.

**Suggested shape.** Two parts.

**Approach A — register the rules.** Wire the v0.33 codemod
rules (`make_settings_config → settings_config` and
`EnvironmentError → HostEnvironmentError`) into the CLI's
version-pair dispatcher so `mgf-common migrate 0.32 0.33 .`
actually runs them. Same shape as the existing `0.1 → 0.3`
registration.

**Approach B — clarify the cutover guide if A is hard.** If
the rules deliberately ship as standalone scripts or as a
library API rather than CLI-dispatched, update the cutover
guide to reflect the actual invocation shape (e.g., "run
`python -m mgf.common.cli._migrate_v033 --apply`" or "import
the rule directly"). The current wording assumes the version-
pair dispatcher works.

**Recommendation.** A. The CLI's version-pair shape is the
documented entry point; consumers shouldn't have to reach into
implementation details to apply a documented codemod.

**Decision rationale.** Accept Approach A as recommended. The
fix is a one-line registry addition: the
`_RULES_0_1_TO_0_3` tuple (the identifier-anchored renames
originally shipped for the v0.1 closed-box redesign) is exactly
what v0.33 needs. The rules are idempotent — they look for old
names that wouldn't be present in already-migrated source — so
registering the same tuple under `("0.32", "0.33")` is safe
for every consumer regardless of which migration path they took
to v0.33.

**Retrospective.** Shipped exactly as the submitter suggested.
The change is two lines of net code: one new tuple entry in
`_MIGRATIONS` and one block-comment explaining the rule reuse.
Module docstring + CHANGELOG entry round it out. Test coverage
landed alongside: five new tests in
`tests/unit/test_phase7_migrate.py::TestPaper43V032ToV033Migration`
cover rename on the new pair, idempotency after a historical
v0.1 → v0.3 codemod run, dry-run threading, and per-rule
import-gate semantics. The existing
`test_unsupported_migration_returns_config_error` was
strengthened to assert both version pairs appear in the
"Supported migrations:" listing, so the dispatch table can't
silently regress in either direction. 1282 tests pass overall.

---

#### <a id="PAPER-44"></a>`PAPER-44` — `mgf-common catch-up --apply` leaves `.bak.<timestamp>` files with no documented cleanup

| Field | Value |
|---|---|
| Status | ✅ SHIPPED |
| Severity | 🟢 Nice-to-have |
| Submitter | inthewords (commit [`76ced0c`](https://codeberg.org/magogi-admin/inthewords/commit/76ced0c)) |
| Submitted | 2026-05-18 |
| Decision | accept (Approach A — auto-clean on verified-clean re-audit; plus the `.gitignore` snippet hint from the submitter's recommendation footnote) |
| Decided on | 2026-05-18 |
| Scheduled for | mgf-common next minor |
| Shipped in | mgf-common commit [`4ee7539`](https://codeberg.org/magogi-admin/mgf-common/commit/4ee7539) |

**Concern.** When `mgf-common catch-up --apply` rewrites a
file (e.g., refreshes the auto-managed block in
`docs/CLAUDE.md`), it writes a `<file>.bak.<unix-timestamp>`
alongside the modified file. The CLI does not delete the backup
after success, surface a guidance message about cleanup, or
provide a `--no-backup` / `--cleanup` flag. The consumer has to
remember to clean these up before committing.

**Why it matters.** A consumer running `catch-up --apply` as
part of a routine mgf-common bump (the documented adoption
walk) will accidentally `git add docs/CLAUDE.md.bak.<ts>` if
they `git add -A`. inthewords' v0.32 bump commit message
(`76ced0c`) explicitly notes manually removing the backup.
Repeat for every catch-up cycle — small but recurring per-bump
friction.

**Concrete evidence.** inthewords commit
[`76ced0c`](https://codeberg.org/magogi-admin/inthewords/commit/76ced0c)
WHAT-CHANGED block:

> `docs/CLAUDE.md` auto-managed block refreshed by `mgf-common
> catch-up --apply`. ... [implied cleanup of `.bak.<ts>` file].

The reproducer:

```
$ .venv/bin/mgf-common catch-up --apply
...
Applying mechanical fixes …
  ℹ️  docs/CLAUDE.md: refreshed auto-block ... (backup:
  CLAUDE.md.bak.1779089558)

Applied 1 mechanical fix(es). Re-run `mgf-common catch-up` to
verify, or check the .bak.* backups if anything looks wrong.

$ ls docs/*.bak.*    # still there after the run
docs/CLAUDE.md.bak.1779089558
```

**Suggested shape.** Three options, increasing in
configurability.

**Approach A — auto-clean on verified success.** After
`--apply` writes the new file AND the re-audit reports clean,
delete the `.bak.<ts>`. If audit still flags issues, keep the
backup. Zero new flags; consumer doesn't think about it.

**Approach B — explicit `--keep-backup` / `--no-backup`
flags.** Default behaviour matches A; flags let consumers
override for paranoia or git-friendliness.

**Approach C — write backups to a `.bak/` subdir** or system
temp dir instead of alongside the source. Less likely to be
caught by `git add -A`. Combine with B for flexibility.

**Recommendation.** A + a `.gitignore` snippet in the
catch-up output's final message ("Add `**/*.bak.*` to your
`.gitignore` to avoid accidentally committing future
backups."). Lowest friction for the routine adoption walk;
preserves safety net for the rare actual-revert case.

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

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

---

#### <a id="PAPER-45"></a>`PAPER-45` — `JsonFormatter` wire shape is take-it-or-leave-it; no gradual-adoption path for already-deployed consumers

| Field | Value |
|---|---|
| Status | ✅ SHIPPED (Approach A) |
| Severity | 🟡 High (blocks formatter adoption indefinitely) |
| Submitter | inthewords (this commit) |
| Submitted | 2026-05-18 |
| Decision | accept Approach A (additive `field_map=` kwarg; Approach C deferred to v2.0) |
| Decided on | 2026-05-19 |
| Scheduled for | shipped in-tree (v1.0 API-gate; Q-14 default lean = SHIP-A) |
| Shipped in | `src/mgf/common/observability/json_formatter.py::JsonFormatter.__init__` (new `field_map: Mapping[str, str] \| None = None` kwarg) + `_RENAMEABLE_CANONICAL_FIELDS` module-level set + format-pass rename + 7 new tests in `tests/unit/observability/test_json_formatter.py::TestPaper45FieldMap` |

**Concern.** `mgf.common.observability.JsonFormatter` emits a
fixed shape: `{ts, level, logger, msg, ...extras flattened,
module, func, line, thread, process}`. A consumer whose existing
log aggregator (Grafana Cloud, ELK, Datadog, Loki) is tuned to a
different shape — e.g., `{timestamp, level, logger, message,
extra: {...}}` with nested extras and `timestamp`/`message` field
names — cannot adopt `JsonFormatter` without rewriting log-parser
config in the aggregator at the same time as the code change.
There is no shape adapter, no rename-map kwarg, and no
composable building blocks (Redactor + OTelEnricher + encoder)
that would let a consumer keep its own wire shape while
inheriting the standards-bearing pieces.

**Why it matters.** A consumer with deployed log-parser rules
faces a binary choice: (a) adopt `JsonFormatter` AND coordinate a
re-deploy of every aggregator parser the same hour, or (b) opt
out entirely and carry a ~250-line shim formatter indefinitely.
Option (a) is operationally expensive and risky (logs unparseable
during the window); option (b) loses the LG-* canonical
formatter and reproduces its redaction / OTel-id enrichment by
hand. inthewords took option (b) — which the "non-adoption is
valid" framing covers conceptually, but the project still pays
the maintenance cost of its own formatter forever.

**Concrete evidence.** inthewords' `apps/core/logging.py` lines
25–85 (module docstring documents the deliberate non-adoption,
Phase 5 outcome). The project emits `{timestamp, level, logger,
message, extra: {...}}` (nested extras, microsecond precision).
Five `TestJsonShapeContract` tests pin the wire format so a
future contributor doesn't silently drift it. The non-adoption
is not because mgf's formatter is wrong — it's because adopting
it requires a coordinated re-deploy that nobody wants to schedule.
Net effect: inthewords adopts LG-01 (get_logger), LG-04
(redaction at serialisation time), LG-09 (`extra={}`), and OTel
span/trace-id propagation — but reproduces all of it locally
because the formatter is monolithic.

**Suggested shape.** Three non-exclusive shapes, by ascending
cost.

**Approach A — field-rename map (cheapest unblocker).**
`JsonFormatter(field_map={'ts': 'timestamp', 'msg': 'message'})`
— a `dict[str, str]` that renames mgf's canonical fields on
emit. Default `field_map={}` (no change). Consumers with
deployed parsers adopt the formatter with one config line.

**Approach B — nested-vs-flat extras flag.**
`JsonFormatter(extras_mode='flat' | 'nested')`. Flat is current
behaviour; nested wraps structured fields under `extra: {...}`
(matches Python's logging `LogRecord` convention and most pre-
existing parsers).

**Approach C — composable building blocks (design-correct).**
Split `JsonFormatter` into `Redactor` + `OTelEnricher` +
`JsonEncoder` so a project composes the parts it needs into its
own formatter. The `Redactor` and `OTelEnricher` ARE the
standards-bearing pieces; the encoder is mechanical. Consumers
keep their wire shape AND inherit the LG-* enforcement work.

**Recommendation.** Ship A first (low-cost unblocker — one
kwarg, ~20 LOC, additive). Schedule C as the next-minor refactor
that subsumes A and B. B is independent quality-of-life that
matches stdlib logging convention.

**Decision rationale.** Q-14 of the v1.0 action plan
(`docs/inprogress/feedback_action_plan.md §7`). Accept Approach A
as the v1.0 ship — additive kwarg, default `None`/`{}` is a strict
no-op, zero migration risk. The docstring at
`src/mgf/common/observability/json_formatter.py:78` already
declares "never rename without a MAJOR bump" — the consumer
correctly identified a closed surface; Approach A is the
non-breaking unblocker for that closure.

Approach B (`extras_mode='flat' | 'nested'`) stays parked — no
second consumer has asked. Approach C (split into
`Redactor` + `OTelEnricher` + `JsonEncoder`) is a constructor
shape change; tracked as the v1.x→v2.0 candidate if a second
consumer's shape mismatch surfaces beyond what `field_map=` can
accommodate.

**Retrospective.** Shipped Approach A verbatim: one kwarg, ~30 LOC
including the canonical-keys whitelist + docstring update + 7
tests. Key implementation notes the docstring captures:

1. The rename applies to **canonical mgf fields only** (the
   `_RENAMEABLE_CANONICAL_FIELDS` frozenset module-level constant
   mirrors the docstring's "Output schema" bullet list). Extras
   keys promoted from `extra={...}` are NOT subject to the map —
   those names come from the caller; rename target would surprise.
2. The rename pass runs **after** the redactor. Redactor scrubs by
   canonical-extras-name (LG-04); the post-rename emit is what
   aggregators see. A consumer's redaction policy doesn't need
   to know about the wire-shape map. Test
   `test_redactor_runs_before_rename` pins this.
3. Insertion order is preserved: a renamed key appears at the
   same position the canonical name would. Test
   `test_field_map_preserves_insertion_order` pins this via raw
   JSON inspection — aggregator-side parsers that walk keys in
   order stay valid.
4. Default (`field_map=None` or `{}`) is a strict no-op:
   byte-identical to pre-PAPER-45 behaviour. Test
   `test_default_is_strict_no_op_pre_paper45_shape` is the
   migration-safety guard.
5. Documented gotcha (test
   `test_field_map_target_collision_later_wins`): mapping two
   canonical names to the same target yields a single key whose
   value is whichever canonical was emitted LATER (dict
   semantics). Don't do that.

inthewords' deployed shape `{timestamp, level, logger, message,
extra: {...}}` is reachable today via `JsonFormatter(redactor=...,
field_map={"ts": "timestamp", "msg": "message"})` — the
`level / logger` keys already match, and the `extra: {...}` nested-
vs-flat shape is Approach B's territory if it ever comes up
again. 1646 tests pass federation-wide.

---

#### <a id="PAPER-46"></a>`PAPER-46` — `AppError.context` dict is mutable after raise; easy to corrupt observability silently

| Field | Value |
|---|---|
| Status | ❌ REJECTED — re-open per criteria below if a second consumer files the same shape |
| Severity | 🟡 High (silent observability foot-gun) |
| Submitter | inthewords (this commit) |
| Submitted | 2026-05-18 |
| Decision | reject (the proposal adds a generic `context` field that contradicts EH-08; the foot-gun the submitter named is best caught by project-side lint, as inthewords already does) |
| Decided on | 2026-05-19 |
| Scheduled for | — (rejected) |
| Shipped in | — (rejected) |

**Concern.** `AppError(message, context={...})` stashes the
context dict by reference. Nothing freezes it. A consumer that
later does `err.context['user_id'] = request.user.id` in a
middleware mutates the same dict that subsequent logging /
serialisation will see. With `mgf.common.observability.AsyncJsonHandler`
queuing log records, the now-mutated context can appear in the
emitted log line under fields that didn't exist when the
exception was raised. Worse, two concurrent requests that share
a cached `AppError` (e.g., a class-level sentinel) see each
other's context mutations.

**Why it matters.** The type signature
(`context: dict[str, Any] | None`) invites mutation — Python
dicts ARE mutable. Adopters who treat context as a normal dict
are not breaking a contract; the library is implicitly trusting
them to know about a contract that isn't written anywhere. The
failure mode is silent: log records show plausible-looking
context fields with values that drift over time, not crashes.
The only signal is "this log line looks wrong" in a Grafana
search, which is hard to triangulate back to the cause.

**Concrete evidence.** During the inthewords central-interfaces
audit (Phase 8D / CENTRAL-04), two places in view code did
`err.context.update({...})` on a re-raised `AppError`. The
project's `TestNoRawDjangoExceptionsInViews` lint catch flagged
them at code-review time — not a production incident, but the
foot-gun is sitting there for any consumer that follows the type
hint at face value. The async logging handler path is the
specific failure mode: `AsyncJsonHandler` queues records by
reference, then the formatter reads `context` at flush time —
after mutations have happened.

**Suggested shape.** Freeze `context` at construction:

```python
from types import MappingProxyType

@dataclass(frozen=True)
class AppError(Exception):
    message: str
    context: Mapping[str, Any]   # not dict — Mapping

    def __post_init__(self):
        # Defensive copy + read-only proxy: immune to source-dict
        # mutation AND blocks `err.context[k] = v` at runtime.
        object.__setattr__(
            self, 'context',
            MappingProxyType(dict(self.context or {}))
        )

    def with_context(self, **extra: Any) -> 'AppError':
        """Return a new AppError with merged context. Cheap."""
        return type(self)(
            self.message,
            context={**self.context, **extra},
        )
```

Adopters who need to enrich the context get `err.with_context(
user_id=..., action=...)` — explicit, type-checkable, impossible
to misuse. The contract becomes "context is immutable after
raise."

Bonus: apply the same shape to `ErrorDetail` if it carries any
mutable container (frozen=True dataclass + MappingProxyType for
any dict fields).

**Why High and not Nice-to-have.** Silent observability data
corruption is the failure mode most teams cannot diagnose from
logs alone — by the time it's noticed, the root cause is
spread across N requests. A library that owns the
exception-to-log path should not leave this hazard exposed in
the type signature.

**Decision rationale.** Q-12 of the v1.0 action plan
(`docs/inprogress/feedback_action_plan.md §2.1 + §7`). Rejected
after code-level review on 2026-05-19. Three findings drove the
flip:

1. **The premise is wrong about the current API.** `AppError`
   today is defined at
   `src/mgf/common/exceptions/_base.py:27` as
   `class AppError(Exception): "..."` — no constructor, no
   `context` field, no `ErrorDetail`. PAPER-46 isn't fixing a
   foot-gun on an existing field; it's proposing to **add** a
   generic `context` bag to the base class. The "type signature
   invites mutation" framing in the submission describes a
   hypothetical API, not the shipped one.
2. **The proposal contradicts EH-08.** `AppError`'s class docstring
   mandates the typed-attribute pattern: subclasses set
   machine-readable attributes (`self.argv`, `self.returncode`,
   etc.) in `__init__` BEFORE calling `super().__init__(message)`.
   That IS the structured-diagnostic shape — typed fields on
   typed subclasses, not a generic dict bag on the base. Adding a
   `context` field would either supersede EH-08 (then the
   standards-corpus contradicts itself) or duplicate it (two ways
   to attach the same data, divergent over time).
3. **inthewords' lint already catches the real problem.** The
   evidence cited in the submission — "two places in view code
   did `err.context.update({...})`" — was caught by inthewords'
   own `TestNoRawDjangoExceptionsInViews` lint at code-review
   time, not in production. That's the correct outcome: EH-08
   prescribes the pattern; project-side lints enforce it.
   mgf-common's job here is to make EH-08 more discoverable, not
   to add a competing API for project-side discipline to police.

**Consumer-facing close-loop notification (per RELEASING.md
REL-09).** To inthewords:

> Thanks for filing PAPER-46. After code-level review against
> the current `AppError` shape (`src/mgf/common/exceptions/_base.py`)
> we're declining the proposal. Reasoning: `AppError` has no
> `context` field today — the typed-attribute pattern in EH-08 is
> the structured-diagnostic shape, and your lint
> (`TestNoRawDjangoExceptionsInViews`) already catches the
> `.context.update(...)` sites you flagged. Adding a generic
> `context` dict would compete with EH-08 rather than reinforce
> it. For your inthewords-side use case, the recommended pattern
> is a typed subclass (e.g.,
> `class ViewError(AppError): action: str; user_id: int`) per
> EH-08; we'll add a recipe showing the shape in
> `docs/recipes/` if a second consumer asks. The paper stays
> filed (not deleted) so future consumers don't re-raise; reopen
> if another consumer hits the same friction AND can't express
> their case via EH-08 typed-attribute subclasses.

**Re-open trigger.** A second consumer (not inthewords) files
the same friction AND demonstrates a concrete case where EH-08
typed attributes can't carry the diagnostic data they need.
Single-consumer signal + EH-08 conflict = REJECT for now.

**Retrospective.** No code changes; this is a status flip with
rationale. The paper stays in the file as a permanent record so
the EH-08-vs-generic-context-bag decision is searchable for
future contributors.

---

#### <a id="PAPER-47"></a>`PAPER-47` — `applies_to:` standards annotations land but the `mgf-common standards` CLI doesn't filter on them

| Field | Value |
|---|---|
| Status | ⏸ DEFERRED |
| Severity | 🟢 Nice-to-have |
| Submitter | inthewords (this commit) |
| Submitted | 2026-05-18 |
| Decision | DEFERRED (2026-05-27) — no consumer has hit the lack-of-filter as a blocker across the federation L2-floor walk-up; queue for v1.x once a second consumer signals demand |
| Decided on | 2026-05-27 |
| Scheduled for | — |
| Shipped in | — |
| Related | [PAPER-31](#PAPER-31) (audit-outcome taxonomy), [PAPER-32](#PAPER-32) (web-shape carve-out cross-refs) |

**Concern.** Post-LOG-02 (shipped v0.18.0), each standards rule
carries `applies_to:` metadata so a Django web consumer can tell
that LG-07 (GUI viewer dialog) is N/A by project kind. But
`mgf-common standards LOGGING` (the documented entry-point per
the CLAUDE.md auto-block recipe) prints the whole topic with no
filter. The consumer scans every rule for the `applies_to:` line
and decides themselves what's relevant. With ~30 rules per topic
across 8 topics, that's manual filtering on every cold read.

**Why it matters.** The standards corpus's primary audience is
adopters running a per-rule conformance audit
(`docs/inprogress/MGF_STANDARDS_CONFORMANCE.md` in inthewords,
mirror docs in PlasmaMapper / vm-vmanager-app). Every audit re-does
the "is this rule applicable to me?" filtering manually,
producing the same N/A rows each time. PAPER-31 and PAPER-32
already identify the audit-side pain (taxonomy of non-✅
outcomes, per-rule cross-refs to carve-outs); this paper is the
CLI-side complement — making the SAME filtering that consumers
do by hand a one-flag invocation.

**Concrete evidence.** inthewords' conformance ledger
(`docs/inprogress/MGF_STANDARDS_CONFORMANCE.md`) was built by
walking every rule and marking ✅ / 🚫 / ⏳ / N/A. The N/A
rationales mostly say "GUI rule, web app — applies_to does not
match" — which is mechanical work the CLI already has the data
to do. The data lives in the docs; nothing reads it.

**Suggested shape.** Two flags on the standards CLI:

```bash
mgf-common standards LOGGING --kind=django     # only applicable rules
mgf-common standards LOGGING --list-na          # only what's N/A
mgf-common standards LOGGING --diff-vs=cli-tool # rules that differ by kind
```

`--kind=` defaults to the project kind detected by
`mgf-common catch-up` (now correctly `django` for projects with
`manage.py`, per PAPER-13). A new consumer running
`mgf-common standards LOGGING` in a Django repo sees the
relevant rules only — onboarding goes from "read 30 rules and
filter 18 out by hand" to "read 12 rules."

**Synergy with PAPER-31 / PAPER-32.** If PAPER-32 ships the
inline "Web-shape note." paragraphs and PAPER-31 ships the
audit-outcome taxonomy, this paper's `--kind=django` filter
becomes the CLI surface that emits exactly the rules the audit
should cover — closing the loop from "what applies to me?" to
"what's my conformance row?" without manual scanning.

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

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

---

#### <a id="PAPER-48"></a>`PAPER-48` — Standards corpus has no browsable URL surface; rule IDs are not citeable in code reviews

| Field | Value |
|---|---|
| Status | ✅ SHIPPED (anchor-stable markdown; mkdocs/sphinx render deferred to v1.1) |
| Severity | 🟢 Nice-to-have |
| Submitter | inthewords (this commit) |
| Submitted | 2026-05-18 |
| Decision | accept partial (anchor-stable markdown now; full static-docs render deferred) |
| Decided on | 2026-05-19 |
| Scheduled for | partial shipped in-tree (v1.0 doc bundle); mkdocs/sphinx render parked for v1.1 |
| Shipped in | inline `<a id="XX-NN"></a>` anchors on 176 rules across 13 topic docs (one Python script pass, idempotent); convention documented in `docs/standards/README.md` |

**Concern.** The canonical way to read a standards rule is
`mgf-common standards LOGGING` (after `pip install`). The PyPI
README lists topics but doesn't render them; the Codeberg repo
has the markdown under `docs/standards/` but the rendered MD on
Codeberg doesn't produce stable per-rule anchors. Net effect: a
reviewer wanting to cite "LG-04" in a PR comment can't link to a
URL — they say "run `mgf-common standards LOGGING` and search
for LG-04."

**Why it matters.** Code review is the moment rules MATTER most
— catching a violation before it lands. The reviewer types
"this violates LG-04," the author asks "what's LG-04?", and now
both have to install mgf-common, run the CLI, and grep. A
citeable URL collapses that round-trip to a hyperlink. Same
problem hits CI bots that want to comment "your PR violates
EH-02, see <URL>" — there's no URL to link to.

**Concrete evidence.** inthewords' CLAUDE.md and conformance
ledger both reference standards by rule ID (`LG-01`, `EH-02`,
etc.) — dozens of references, zero with a URL behind the ID.
The closest precedent is the
`docs/standards/principles/non-adoption-is-valid.md` link
referenced from inthewords' MGF_FEEDBACK.md — it works because
Codeberg renders the markdown file at a predictable URL, but
the rule IDs inside the file have no anchor of their own.

**Suggested shape.** A static docs site (mkdocs / sphinx /
even a single rendered `STANDARDS.html` in the wheel) hosted
under the canonical project page. Bonus: stable rule-anchor URLs
so `https://docs.mgf-common.dev/standards/logging/#LG-04`
becomes a citeable link.

The same rendering pipeline can also emit:
- The `--diff-vs=` view from PAPER-47 as a static "kind
  comparison matrix" page.
- The "Web-shape note." cross-refs from PAPER-32 as inline
  rendered prose (rather than a separate `web/README.md`).
- The audit-outcome taxonomy from PAPER-31 as a glossary page.

All three open papers benefit from the same docs-site
infrastructure. Filing this one captures the dependency.

**Decision rationale.** Accept the citeable-URL gap; split the
fix into "anchor-stable markdown now" (cheap, immediate win) and
"static docs render later" (mkdocs/sphinx pipeline, deferred to
v1.1). Anchor-stable markdown is sufficient for citing rules in
PR comments on Codeberg; the static-site render is a UX upgrade
the v1.0 cut doesn't need. Defers the heavier infrastructure
question past v1.0 lock without losing the immediate value.

**Retrospective.** Shipped as inline HTML anchors:
`<a id="XX-NN"></a>` immediately before each `**XX-NN**` rule ID
in every Rules-box table cell. Applied mechanically via a Python
script (`/tmp/add_anchors.py`) targeting only the "## Rules box"
+ "## Per-kind compliance matrix" sections in `docs/standards/*.md`
to avoid duplicate anchors from in-prose rule references. 176
anchors added across 13 topic docs: ADP, AP, CF, DEP, DOC, DP,
EH, FB, LG, OP, RA, REL, SC, SP, TS, WF prefix families. Idempotent
(re-running on already-anchored rules is a no-op). Convention
documented in `docs/standards/README.md` with the Codeberg URL
shape (`...CONFIGURATION.md#CF-09`) so future PR comments can cite
directly. The mkdocs/sphinx render carries to v1.1 as a follow-up;
the rule-ID anchors won't change shape, so the render builds on
top of what shipped here.

**v1.1 follow-up.** The full static-docs site (mkdocs material is
the recommended choice for consistency with the broader Python
ecosystem; one-shot `STANDARDS.html` in the wheel is a lower-
overhead alternative). The anchor scheme established here will
survive whichever render pipeline lands.

---

#### <a id="PAPER-49"></a>`PAPER-49` — pydantic + pydantic-core in install footprint for consumers who don't use `BaseAppSettings`

| Field | Value |
|---|---|
| Status | ❌ REJECTED — re-open per criteria below if a second consumer files concrete deploy pain |
| Severity | 🟢 Nice-to-have |
| Submitter | inthewords (this commit) |
| Submitted | 2026-05-18 |
| Decision | reject (the submission underestimates pydantic's blast radius — it's load-bearing in 4 subsystems, not just `BaseAppSettings`; the multi-extra refactor isn't justified by the ~3 MB / ~100 ms savings) |
| Decided on | 2026-05-19 |
| Scheduled for | — (rejected) |
| Shipped in | — (rejected) |

**Concern.** `pip install mgf-common` pulls pydantic 2.x +
pydantic-core (~3 MB of wheels including a binary). Consumers
adopting mgf-common for the exception hierarchy / logging /
standards CLI but NOT for `BaseAppSettings` (typical for Django
under the CONFIG-01 carve-out, where `django.conf.settings` IS
the canonical settings module) carry pydantic as dead weight in
the wheel. Same applies to CI containers running only the
standards CLI, where wheel size affects cold-start time.

**Why it matters.** Not a practical blocker for typical dev/prod
environments. But it's a data point worth raising for the
adoption shape:

- Web consumers (Django, FastAPI's stdlib-only adopters, Flask)
  using mgf-common under CONFIG-01 carve-outs never instantiate
  `BaseAppSettings`. Pydantic adds zero value, ~3 MB of disk +
  measurable import time.
- CLI-tool / CI containers running `mgf-common catch-up` or
  `mgf-common standards` as a one-shot audit don't need
  pydantic. The standards CLI surfaces text from `docs/`, the
  catch-up tool reads pyproject.toml / requirements files.
- Future thin consumers (embedded systems, lambda functions,
  edge workers) face the same overhead with no upside.

**Concrete evidence.** inthewords' `requirements/base.txt`
comment block around the mgf-common pin records the pydantic
footprint observation from Phase 0 (originally flagged as
PAPER-C and judged "🟢 cosmetic; defer until concrete pain
surfaces"). With four+ adoptees now (vm-vmanager-app, PlasmaMapper,
inthewords, iosRecovery — most under non-Settings consumer
shapes), the pattern is consistent enough to file.

**Suggested shape.** Split the package via extras:

- `mgf-common` (core, default): exception hierarchy,
  `get_logger`, standards CLI, redactor, OTel hooks, request-id
  contextvar. No pydantic.
- `mgf-common[settings]` extra: pulls pydantic + exposes
  `BaseAppSettings`.

`pip install mgf-common` stays the default for the
exceptions/logging consumers (under CONFIG-01 carve-out). CLI-
tool and desktop-app consumers opt in to `[settings]` if they
want the typed config. Same extras pattern as
`mgf-common[observability]` already establishes — the
infrastructure is in place.

Code change: move `mgf.common.config` to import pydantic lazily
(inside the `BaseAppSettings` class definition or via a
deferred-import shim). Add `settings = ["pydantic>=2,<3"]` to
`[project.optional-dependencies]`. Document in CHANGELOG.

Migration risk: zero for consumers already pinning a recent
version — they explicitly `import mgf.common.config` if they
use Settings, and the new shape is `pip install mgf-common[settings]`
which the next adoption-walk picks up.

**Decision rationale.** Q-13 of the v1.0 action plan
(`docs/inprogress/feedback_action_plan.md §2.2 + §7`). Rejected
after code-level review on 2026-05-19. Two findings drove the
flip:

1. **pydantic's blast radius is larger than the submission
   assumed.** The submission's premise ("consumers adopting
   mgf-common for the exception hierarchy / logging / standards
   CLI but NOT for `BaseAppSettings` carry pydantic as dead
   weight") only holds if pydantic is isolated to
   `mgf.common.config._settings`. Code-level audit found pydantic
   imports in **7 modules across 4 subsystems**:

   ```
   src/mgf/common/vault/_configs.py          # vault backends
   src/mgf/common/versioned/_store.py        # versioned file store schema
   src/mgf/common/cli/_validate.py           # `mgf-common validate`
   src/mgf/common/cli/_provenance.py         # `mgf-common provenance`
   src/mgf/common/config/_settings.py        # BaseAppSettings (expected)
   src/mgf/common/config/_loader.py          # layered loader
   src/mgf/common/config/_pyproject_source.py
   src/mgf/common/_env_source.py
   src/mgf/common/settings.py                # re-export
   ```

   Splitting cleanly would require multiple extras —
   `[settings]`, `[vault]`, `[versioned]`, `[cli-validate]` — a
   4-axis install matrix that every existing consumer (vm-vmanager-app,
   PlasmaMapper, inthewords, iosRecovery) would have to learn and
   pin. The "~50 LOC + lazy imports" estimate in the action-plan
   v1 was wrong; this is a sub-system refactor.

2. **The cost / benefit doesn't pencil out.** ~3 MB disk + ~100ms
   import savings vs. a 4-extra install matrix the federation has
   to communicate, and per-consumer pin walks at every install.
   pydantic is the de-facto schema library across mgf-common; the
   ergonomics tax outweighs the resource savings, especially
   given there's no concrete deploy-time pain on the docket — the
   submission's evidence is disk-size + import-time, not
   cold-start latency at scale or container-budget overrun.

**Consumer-facing close-loop notification (per RELEASING.md
REL-09).** To inthewords:

> Thanks for filing PAPER-49. We're declining the split-via-
> extras proposal after code-level review. Reasoning: pydantic
> is integrated across four subsystems (vault, versioned-file-
> store, config, CLI validate/provenance — 7 import sites), not
> just `BaseAppSettings`. Splitting cleanly would require a
> 4-extra install matrix (`[settings]`, `[vault]`, `[versioned]`,
> `[cli-validate]`) that every existing consumer would have to
> learn, and the ~3 MB / ~100 ms savings don't justify the
> ergonomics tax. Your CONFIG-01 carve-out shape is fully
> supported today — `mgf-common` is the cornerstone install
> regardless of whether the consumer instantiates
> `BaseAppSettings`. The paper stays filed (not deleted); re-
> open if a second consumer reports concrete deploy-time pain
> (cold-start at scale, container-size budget overrun, or a
> pydantic 3.x / PEP 711 landscape shift). We'll document the
> pydantic-coupling explicitly in `COMMON_COMPONENTS.md` as a
> follow-up so future thin-consumer evaluators can size the cost
> before adoption (tracked as a v1.x follow-up).

**Re-open trigger.** Either of:

- PEP 711 (pylock.toml) or a pydantic 3.x release that materially
  changes the disk-footprint or import-time math (e.g., a
  zero-dependency mode for `BaseModel`-free schema).
- A second consumer (not inthewords) files concrete deploy-time
  pain (cold-start latency at scale, container-budget overrun,
  edge / lambda / embedded shape) from pydantic in the install.
  Disk-size + theoretical import-time on dev machines is not
  enough.

**Retrospective.** No code changes; this is a status flip with
rationale. The pydantic-coupling commentary belongs in
`docs/standards/COMMON_COMPONENTS.md` as a v1.x follow-up doc
addition — explicit acknowledgment that pydantic is woven across
4 subsystems lets future thin-consumer evaluators size the cost
before adoption.

---

#### <a id="PAPER-54"></a>`PAPER-54` — `mgf-common catch-up` hardcodes `docs/CLAUDE.md`, but DOC-07 specifies `docs/claude/CLAUDE.md`

| Field | Value |
|---|---|
| Status | ✅ SHIPPED |
| Severity | 🟡 High |
| Submitter | vm-vmanager-app (commit [`8ec0075`](https://codeberg.org/magogi-admin/vm-vmanager-app/commit/8ec0075)) |
| Submitted | 2026-05-18 |
| Decision | accept (Approach A — accept both paths; canonical DOC-07 wins; legacy duplicate flagged via info-row in the same finding's detail) |
| Decided on | 2026-05-19 |
| Scheduled for | shipped in-tree (v1.0 catch-up bundle) |
| Shipped in | `src/mgf/common/cli/_catch_up.py::_resolve_claude_md_path` + refactored `_check_claude_md` + `_apply_*_claude_md*` helpers honour the resolved active path; 4 new tests in `tests/unit/cli/test_catch_up.py::TestClaudeMdCheck` |

**Concern.** The catch-up tool's `_check_claude_md` hardcodes the path `target_dir / "docs" / "CLAUDE.md"` ([`src/mgf/common/cli/_catch_up.py:1080`](https://codeberg.org/magogi-admin/mgf-common/src/branch/main/src/mgf/common/cli/_catch_up.py)). But [DOC-07 in `docs/standards/DOCUMENTATION.md:31`](https://codeberg.org/magogi-admin/mgf-common/src/branch/main/docs/standards/DOCUMENTATION.md) says: "Every project MUST ship a `docs/claude/CLAUDE.md`." Two source-of-truth paths inside the same federation.

**Why it matters.** Consumers who follow DOC-07 (file at `docs/claude/CLAUDE.md`) get `🔴 docs/CLAUDE.md missing` from the catch-up audit on every run and can never reach exit 0. Now that 0.38 ships `_check_federation_contract` and the federation-sync message recommends adding `mgf-common catch-up` to CI as an L2/L3 gate, the path divergence forces a no-win choice: (a) violate DOC-07 by also creating `docs/CLAUDE.md` (the dual-file shape vm-vmanager-app landed in commit `8ec0075` — a pragmatic resolution but inelegant), (b) violate the federation contract by leaving `docs/CLAUDE.md` missing, or (c) skip the CI gate. Every consumer who reaches L2/L3 hits this.

**Concrete evidence.** vm-vmanager-app `MGF-CLAUDEMD-01` row in [`docs/MGF_COMMON_CATCHUP.md`](https://codeberg.org/magogi-admin/vm-vmanager-app/src/branch/main/docs/MGF_COMMON_CATCHUP.md) decided "keep as-is" Rounds 2–5 (2026-05-02 → 2026-05-17), then was REVERSED in Round 6 (2026-05-18, commit `8ec0075`) under federation-gate pressure to create the duplicate file. The session-wide MGF-CLAUDEMD-01 entry already records four separate rounds of the same finding being raised and re-deferred — that is exactly the consumer-side cost the divergence imposes.

**Suggested shape.** Make the tool accept either path.

- **Approach A — accept both** (recommended). Change `_check_claude_md` to look at `("docs", "claude", "CLAUDE.md")` first (DOC-07 canonical), then fall back to `("docs", "CLAUDE.md")` (legacy). When both exist, audit only the canonical one and emit an informational `ℹ` row about the legacy duplicate. Zero-disruption migration: existing consumers stay green, new consumers can land at the DOC-07 path on day one. Same one-list-of-candidates pattern that `_check_federation_contract` already uses for the universal-five and MGF_ADOPTION variants.
- **Approach B — deprecate `docs/CLAUDE.md`**. Tool warns for one MINOR ("path is moving to `docs/claude/CLAUDE.md` per DOC-07; please move your file"), then flips to require the new path. Cleaner long-term, more disruptive short-term.

**Decision rationale.** Accept Approach A as recommended. Zero
forced-migration cost on existing consumers; new consumers land
at the DOC-07 canonical path on day one. Approach B is the v1.x
follow-up if it turns out the dual-path acceptance grows surface
beyond what's worth carrying.

**Retrospective.** Shipped as Approach A verbatim. New helper
`_resolve_claude_md_path(target_dir) -> tuple[Path | None, bool]`
returns the active path (canonical preferred) and a flag indicating
a legacy duplicate. `_check_claude_md` consults the helper; the
duplicate-flag becomes a one-sentence info note appended to the
finding's detail (no separate finding row — fewer surface flags
to lift). All three `_apply_*_claude_md*` helpers operate on the
resolved active path, so refresh / bootstrap-markers stay in
place on legacy consumers without forcing a path migration. The
sister-paper PAPER-55 (silent duplicate creation under `--apply`)
also gets the partial fix as a side-effect: `_apply_create_claude_md`
detects an existing active file and refreshes it instead of
duplicating. Full PAPER-55 (warn-instead-of-fail + auto-pointer-
footer) remains v1.x. New tests:
`test_canonical_path_audited_when_only_canonical_exists`,
`test_legacy_path_audited_when_only_legacy_exists`,
`test_canonical_wins_when_both_paths_exist_with_duplicate_note`,
`test_missing_finding_points_at_canonical_path`. All 115 catch-up
tests green; full federation suite 1639/0.

---

#### <a id="PAPER-55"></a>`PAPER-55` — `catch-up --apply` silently creates a duplicate `docs/CLAUDE.md` when `docs/claude/CLAUDE.md` already exists

| Field | Value |
|---|---|
| Status | ⏸ DEFERRED |
| Severity | 🟢 Nice-to-have |
| Submitter | vm-vmanager-app (commit [`8ec0075`](https://codeberg.org/magogi-admin/vm-vmanager-app/commit/8ec0075)) |
| Submitted | 2026-05-18 |
| Decision | DEFERRED (2026-05-27) — companion to PAPER-54 (which shipped); no second consumer has hit the duplicate-CLAUDE.md scenario across the L2-floor walk-up. Re-open if a second consumer files the same shape. |
| Decided on | 2026-05-27 |
| Scheduled for | — |
| Shipped in | — |

**Concern.** Sister-paper to `PAPER-54`. Even if the path divergence stays, the `--apply` step that creates `docs/CLAUDE.md` does not detect a pre-existing `docs/claude/CLAUDE.md` and silently writes a parallel template-driven 309-line file. A consumer who runs `catch-up --apply` without reading the output ends up with two divergent CLAUDE.md files in different directories and no signal that there is a tension.

**Why it matters.** vm-vmanager-app's `docs/claude/CLAUDE.md` is a 1348-line hand-curated lookup that grew over 5 standards rounds. A less-attentive consumer running `--apply` would have committed the duplicate, then later wondered which file is canonical — and might have edited the auto-managed `docs/CLAUDE.md` (which gets rewritten on next `--apply`) instead of the canonical `docs/claude/CLAUDE.md`. Silent file creation in a docs directory that already holds the consumer's main AI lookup is a footgun.

**Concrete evidence.** vm-vmanager-app session 2026-05-18 — the first `--apply` invocation in Round 5 (2026-05-17) created a parallel file that was deleted before commit; the Round 6 follow-up did the same and resolved it only by adding an explicit pointer footer in the auto-generated file. Two `--apply` cycles in two days, same surprise both times.

**Suggested shape.** If `docs/claude/CLAUDE.md` exists at `--apply` time, do one of:

- (a) Emit a `🟡 warn` instead of the `🔴 fail` finding, with body "`docs/claude/CLAUDE.md` exists (DOC-07 canonical). The tool can either (1) create a thin `docs/CLAUDE.md` pointer to it [recommended], or (2) skip the `docs/CLAUDE.md` audit if PAPER-54 ships." Don't auto-create.
- (b) When `--apply` does create `docs/CLAUDE.md` over an existing `docs/claude/CLAUDE.md`, auto-include the pointer footer (no need for every consumer to discover this independently).
- (c) Both (a) when reading + (b) when applying. Best UX.

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

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

---

#### <a id="PAPER-56"></a>`PAPER-56` — `USERS.md` surface attributions drift from reality; should be auto-verified from consumer source

| Field | Value |
|---|---|
| Status | ⏸ DEFERRED |
| Severity | 🟢 Nice-to-have |
| Submitter | vm-vmanager-app (commit [`8ec0075`](https://codeberg.org/magogi-admin/vm-vmanager-app/commit/8ec0075)) |
| Submitted | 2026-05-18 |
| Decision | DEFERRED (2026-05-27) — auto-verification is bigger structural work (`mgf-common` would need a consumer-source-scanning helper; the rename recipe under PAPER-79-A already calls out USERS.md as a touch-point in Phase C step 11). The 2026-05-27 federation walk-up surfaced + manually fixed drift in mgf-sqlalchemy (underscore URL) and mgf-vm USERS.md (stale pin window). Manual hygiene during cohort walks is sufficient for now; revisit when a 3rd USERS.md drift instance surfaces. |
| Decided on | 2026-05-27 |
| Scheduled for | — |
| Shipped in | — |

**Concern.** `mgf-common/USERS.md` lists vm-vmanager-app's consumed surfaces as `bootstrap / setup_logging / process / vault / progress`. A fresh import grep of vm-vmanager-app's `src/vmanager/` returns: `mgf.common, mgf.common.cli, mgf.common.config, mgf.common.exceptions, mgf.common.observability, mgf.common.progress, mgf.common.settings, mgf.common.versioned` — neither `process` nor `vault` appears. The hand-maintained surface column is wrong for at least one of four listed consumers and went unnoticed for an unknown duration.

**Why it matters.** USERS.md is referenced in the FEDERATION.md contract and used for federation-roster-wide reasoning (which siblings to prioritize, who's adopting what). When surface attributions drift, the data-quality signal of USERS.md drops; "vm-vmanager-app uses mgf.common.vault" is the kind of false-positive that makes a sibling maintainer plan for a consumer they don't actually have. Hand-maintenance at federation scale is a losing battle as the consumer count grows past four.

**Concrete evidence.** [`mgf-common/USERS.md:25`](https://codeberg.org/magogi-admin/mgf-common/src/branch/main/USERS.md) row for vm-vmanager-app: `Surfaces: bootstrap / setup_logging / process / vault / progress`. Actual ground truth:

```
grep -rh "^from mgf\." vm-vmanager-app/src/vmanager/ | awk '{print $2}' | sort -u
mgf.common
mgf.common.cli
mgf.common.config
mgf.common.exceptions
mgf.common.observability
mgf.common.progress
mgf.common.settings
mgf.common.versioned
```

vm-vmanager-app's own `MGF_ADOPTION.md §2` (commit `61cf070`) records the correction.

**Suggested shape.** Generate USERS.md's per-consumer surface column from a script that:

- For each entry in §2's consumer roster, clones (or pulls) the consumer repo to a configured local path.
- Runs `grep -rh "^from mgf\." <consumer>/src/`, extracts module names, distincts them.
- Rewrites just the "Surfaces" column for each row.
- Diffs against the committed USERS.md; non-zero diff fails CI (so drift is caught at PR time) OR auto-opens a PR (so the surface column self-updates).

Same pattern as `mgf-common catch-up`'s federation-contract check — federation hygiene should be machine-enforced where it can be. The script lives at `scripts/refresh_users_md.py` or as a `mgf-common federation refresh-users` subcommand.

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

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

---

#### <a id="PAPER-57"></a>`PAPER-57` — Federation-sync message recommends a pin window that doesn't include the federation-contract release

| Field | Value |
|---|---|
| Status | ✅ SHIPPED |
| Severity | 🟡 High |
| Submitter | vm-vmanager-app (commit [`8ec0075`](https://codeberg.org/magogi-admin/vm-vmanager-app/commit/8ec0075)) |
| Submitted | 2026-05-18 |
| Decision | accept (Approach A via new CLI subcommand — message templates from `__version__` at invocation time) |
| Decided on | 2026-05-19 |
| Scheduled for | shipped in-tree (v1.0 catch-up bundle) |
| Shipped in | new `mgf-common federation-sync` subcommand (`src/mgf/common/cli/_federation_sync.py`) + shared helper `src/mgf/common/cli/_version_pin.py::current_pin_window` + tests in `tests/unit/cli/test_federation_sync.py` + `tests/unit/cli/test_version_pin.py` |

**Concern.** The "mgf-common federation sync — please run before next merge" message (pasted verbatim into vm-vmanager-app's chat 2026-05-18) reads: "Bump your mgf-common pin in `pyproject.toml` to `>=0.37,<0.38`, then `uv sync`." But 0.38 is the federation-contract release that ships FEDERATION.md, USERS.md, DOC-16, DOC-17, and the `_check_federation_contract` audit step in `catch-up`. Pinning to 0.37 misses the federation-contract enforcement the message is asking the consumer to align with.

**Why it matters.** The sync message is the federation's primary onboarding interface; every consumer hits it once. A version mismatch in the message body means every consumer either (a) does what the message says and silently misses the federation contract, or (b) catches the mismatch (as vm-vmanager-app did, because we checked PyPI directly) and has to mentally substitute. Either way, the message degrades the trust signal on federation-wide communications.

**Concrete evidence.** vm-vmanager-app session transcript 2026-05-18: the user pasted the sync message verbatim while resuming after PyPI publication. Checking PyPI returned `latest: 0.38.0`, releases ending `0.34.0, 0.38.0` (0.35/0.36/0.37 were also published the same day but not "the canonical contract release"). vm-vmanager-app landed `>=0.38,<0.39` in commit `212808f` after the discrepancy was surfaced.

**Suggested shape.** Two complementary fixes:

- **Resolve at send time**: federation-sync message templates parametrize `{LATEST_VERSION}` and `{NEXT_MINOR}` from `mgf-common --version` or PyPI's JSON API at the moment the message is generated. The message body always carries the version that was current when the message was sent. (If the message is templated in an AI-agent prompt, the same parametrization applies.)
- **Pin-to-latest discipline in the message body**: instead of a single `>=0.X,<0.Y` example that goes stale, say "pin to the latest MINOR — check PyPI for the current version, then write `mgf-common>=<latest>,<<latest+1>`." Less convenient but immune to message rot.

Approach A is the better long-term solution; Approach B is the immediate-fix template until A lands.

**Decision rationale.** Accept Approach A. Wherever the message is
generated (chat session, automation, AI agent), the canonical
source is now `mgf-common federation-sync` — invoke it, paste the
output. The pin window is always live because it's computed from
the installed `mgf.common.__version__` at invocation time.

**Retrospective.** Shipped as a new CLI subcommand
(`mgf-common federation-sync`) that prints the templated message
with the live pin window. Shared helper
`mgf.common.cli._version_pin.current_pin_window()` is the single
source of truth (also used by PAPER-52's init-project fix). The
message body covers the four-step consumer flow: bump pin → `uv
sync` → `mgf-common catch-up` to verify → walk sibling pins per
`docs/cutover/federation-v<X.Y>.md`. Smoke-test on v0.38.0
produced `"mgf-common>=0.38,<0.39"` correctly. Approach B
(documenting the convention in prose) is implicitly satisfied:
the subcommand's source code IS the canonical template.

---

#### <a id="PAPER-59"></a>`PAPER-59` — `mgf-common init-project` lacks a `gui` kind; desktop-GUI consumers scaffold as `service` and accept a permanent mislabel

| Field | Value |
|---|---|
| Status | ✅ SHIPPED |
| Severity | 🟢 Nice-to-have |
| Submitter | hrb-app (initial scaffold 2026-05-24, `/home/bassam/PycharmProjects/hrb_app`) |
| Submitted | 2026-05-24 |
| Decision | accept Option 1 (cheapest) — add `gui` to `--kind` enum + framework-agnostic GUI quick-start. Option 2 (`docs/recipes/gui.md`) and Option 3 (catch-up GUI-framework detection) deferred until a second GUI consumer materialises, per the paper's own deferral guidance |
| Decided on | 2026-05-24 |
| Scheduled for | next federation release |
| Shipped in | `src/mgf/common/cli/_init_project.py` — `gui` added to `_KINDS` / `_VALID_KIND` / `--kind` help; `_QUICK_START_BLOCKS["gui"]` carries a sync `app.run()` shape (NOT the `serve()` async loop); per-kind deps map adds `gui → [mgf-common]` only (framework is the consumer's choice, no http / fastapi / django siblings forced). Tests in `tests/unit/cli/test_init_project.py` parametrize over the new kind + add `TestQuickStartBlocksNoClosedBoxLeaks::test_paper_59_gui_kind_shipped` as a regression pin |

**Concern.** `mgf-common init-project --kind {cli,fastapi,django,library,service}` covers headless project shapes only. A desktop-GUI consumer (Qt/PySide, Textual, Tauri-hybrid, etc.) has no matching kind. The closest stand-in — `service` — produces a working `pyproject.toml` + four gates + `docs/CLAUDE.md`, but the docs and recipes are wrong-shaped: the auto-block's bootstrap example talks about a `serve()` loop ("# … your service loop …") rather than a GUI event loop, and the CLAUDE.md preamble pins the project as `service` permanently. Consumers either accept the wrong label or `--force`-overwrite + hand-edit on every regeneration.

**Why it matters.** The Magogi federation isn't headless-only — `mgf-hrb` is a SDK whose first consumer is *this* project (`hrb-app`, a desktop GUI front-end). If the federation grows more GUI consumers (operator dashboards, configuration UIs for `mgf-fastapi` services, future iosRecovery / vm-vmanager-app GUI front-ends) every one will hit the same friction and either (a) silently lie about its kind in the auto-block, (b) carry a paragraph in CLAUDE.md explaining the mismatch (as `hrb-app` now does), or (c) lobby for the kind via a paper. (c) is cleaner once. The cost compounds with each new GUI consumer; the fix is one new template.

A second, smaller signal: `mgf-common catch-up` runs detect kind = `library` from an empty pyproject (no console scripts yet). A `gui` kind with its own conventional entry-point shape (e.g., `[project.scripts] hrb-app = "hrb_app.__main__:main"` + an async-friendly GUI bootstrap recipe) would let catch-up's kind-detection heuristic notice the GUI shape too.

**Concrete evidence.** `hrb-app` session, 2026-05-24:

- `mgf-common init-project --name hrb-app --kind service --dry-run` chosen as the closest fit (after asking the operator). The scaffolded `docs/CLAUDE.md` carries a `serve()` async-loop bootstrap example at `## Quick start — bootstrapping this project`; `hrb-app`'s real `main()` will be a Qt/Textual event-loop entry point with no `asyncio.run(serve())` shape.
- The project's `docs/CLAUDE.md` now carries a "Scaffolding gotchas" section explaining the mismatch (kind = service stand-in; PAPER filed) — boilerplate that every future GUI consumer would re-author independently.
- `MGF_ADOPTION.md` for `hrb-app` cross-references this PAPER under §5 (Round 1) so the closure trail survives.

**Suggested shape.** Three options, ordered by impact-to-effort:

- **(Cheapest)** Add `gui` to the `--kind` enum; reuse the `service` templates but swap the `serve()` example for a framework-agnostic GUI bootstrap sketch (sync `app = Framework(); app.run()` with a comment that `mgf.common.bootstrap()` wraps it). One enum entry, one template-file swap.
- **(Medium)** Add `gui` *and* ship a `docs/recipes/gui.md` (alongside `cli.md`, `script.md`, `notebook.md`) covering the canonical layering pattern for a GUI consumer: presentation MUST NOT be imported by core (`DP-01`), the GUI event loop's interaction with `mgf.common.bootstrap()`'s async context, settings persistence (`BaseAppSettings` for window geometry / preferences), and the standard `import-linter` contract that encodes the layering. The recipe is where the actual unique GUI advice lives.
- **(Most thorough)** As (Medium), plus extend `catch-up`'s kind-detection heuristic to recognise a GUI consumer (presence of a GUI-framework dep — PySide6 / PyQt6 / textual / pywebview / pyside-based — or a `gui` marker in pyproject). Removes the "detected as library because no scripts" false positive.

(1) ships immediately and unblocks `hrb-app` from carrying the gotcha section forever. (2) is the right shape long-term — the GUI layering pattern is the actual federation contribution; the kind is just packaging. (3) lands when there are ≥2 GUI consumers in the wild.

**Cost if deferred past v1.0.** Low — `service` is a workable stand-in, and the per-project gotcha paragraph in CLAUDE.md is cheap (~12 lines). The cost is N consumers each writing the same gotcha paragraph independently, and the auto-block carrying a `serve()` example to every GUI consumer that has to be mentally translated. If `hrb-app` stays the only GUI consumer, deferring costs ~nothing; the moment a second GUI consumer arrives, ship (1) at minimum.

**Decision rationale.** `hrb-app` is the federation's first GUI consumer and the trigger was the operator hand-holding `--kind service` through a "this isn't quite right" interactive moment. Option 1 is one enum entry + one template block + a couple of test-parametrize edits — strictly additive, no API break, no doc reflow. It removes the gotcha paragraph from `hrb-app`'s CLAUDE.md and stops the next GUI consumer (`hrb-tui`, future operator dashboards) from re-discovering the same friction. Options 2 and 3 are deferred per the submitter's own guidance: the GUI layering recipe is the right long-term shape but writing it speculatively from a single consumer would calcify one project's choices into a federation standard, and the catch-up heuristic improvement waits on a second consumer's `pyproject.toml` to validate the marker shape.

**Retrospective.** Shipped same-day (2026-05-24) inside `mgf-common` 0.38.x → next release. The fix is a six-line addition to `_init_project.py`'s constants + a ~14-line `_QUICK_START_BLOCKS["gui"]` entry; the deps map gained `"gui": [mgf_pin]` only — deliberately framework-agnostic so consumers pick PySide6 vs. textual vs. pywebview without the federation prescribing one. Tests use the same parametrize lists that already cover the other five kinds; the `test_paper_59_gui_kind_shipped` regression pin asserts the quick-start does NOT carry the `serve()` shape and DOES carry `app.run()` + only `mgf-common` (no http sibling). `hrb-app`'s `docs/CLAUDE.md` can drop its "Scaffolding gotchas" section on its next `mgf-common catch-up --apply` once it's pinned to the release carrying this paper.

---

#### <a id="PAPER-58"></a>`PAPER-58` — `mgf-common doctor` flags `[vault]` extra missing as `⚠` for every consumer with its own vault implementation

| Field | Value |
|---|---|
| Status | ✅ SHIPPED |
| Severity | 🟢 Nice-to-have |
| Submitter | vm-vmanager-app (commit [`8ec0075`](https://codeberg.org/magogi-admin/vm-vmanager-app/commit/8ec0075)) |
| Submitted | 2026-05-18 |
| Decision | accept Approach 1 (downgrade vault → ℹ); defer Approaches 2 + 3 until evidence accrues (2026-05-27) |
| Decided on | 2026-05-27 |
| Scheduled for | mgf-common 0.41.6 |
| Shipped in | mgf-common 0.41.6 |

**Concern.** `mgf-common doctor` emits `⚠ [vault] extra not installed (missing: keyring) — corresponding mgf.common surface unavailable` on every invocation in a consumer environment that has its own vault module. The warning is correct in the abstract (the consumer can't use `mgf.common.vault.*`) but irrelevant for consumers that don't intend to.

**Why it matters.** Multiple consumer projects already have their own vault implementations — vm-vmanager-app (`src/vmanager/vault/keyring.py`, mature since long before `mgf.common.vault` existed); iosRecovery presumably similar. Every audit cycle for every such consumer surfaces the same ⚠, the same `accept-with-rationale` row gets re-noted, the warning becomes noise the consumer has to mentally filter every audit run. As doctor's role grows (mentioned in the federation-sync message alongside catch-up), the noise compounds.

**Concrete evidence.** vm-vmanager-app session 2026-05-18 — `mgf-common doctor` invoked twice (Round 5 and Round 6), same ⚠ both times. vm-vmanager-app's `MGF-DOCTOR-01` row in `docs/MGF_COMMON_CATCHUP.md` formally accepts the warning with rationale. The accept-with-rationale row pattern shows the cost is non-zero: a consumer must spend a sentence explaining why the warning is OK every time the doc is re-audited.

**Suggested shape.** Three options, ordered by impact-to-effort:

- **(Cheapest)** Downgrade severity from `⚠` to `ℹ` when the missing extra is `vault` only. Vault is genuinely optional — consumers can BYO. The `ℹ` signal communicates "you can install this if you want the mgf surface" without implying "something is wrong."
- **(Medium)** Add a `--ignore-extra <name>` flag so consumers can suppress in CI without inventing per-project filters. `mgf-common doctor --ignore-extra vault` exits clean for vm-vmanager-app-shaped consumers.
- **(Most thorough)** Auto-detect a project-local vault implementation (e.g., `src/<project>/vault*.py` or anything matching a `KeyringBackend` Protocol) and downgrade the warning when one is found. Implies a Protocol convention so consumers can opt in.

Approach 1 is recommended as the first ship; Approach 2 lands when the consumer count grows past five.

**Decision rationale.** Accepted Approach 1 in mgf-common 0.41.6. The cheapest change closes the immediate noise for every BYO-vault consumer (vm-vmanager-app, iosRecovery, future). Approach 2 (`--ignore-extra` flag) deferred until evidence accrues that a 2nd extra needs similar treatment; Approach 3 (auto-detect project-local vault) deferred as overkill — the convention burden (Protocol shape) doesn't pay for itself with only one severity-downgrade case.

**Retrospective.** Shipped 2026-05-27 in mgf-common 0.41.6.

Added a per-extra severity table (`missing_severity: dict[str, str]`) in `_check_extras`. Default is ⚠ ("operator should notice"); `vault` opts into ℹ ("informational"). `observability` stays at ⚠ — it's the federation default and a missing observability extra is a real signal. The structure leaves future extras one row away from joining `vault` if they ever land in the same "BYO is normal" category.

2 new regression pins in `tests/unit/test_phase6b_diagnostic_cli.py::TestDoctorBundleCheck`:
- `test_paper_58_vault_missing_is_info_not_warn` — patches `_module_installed` to simulate `keyring` + `cryptography` absent, asserts the vault line starts with `ℹ `.
- `test_paper_58_observability_missing_stays_warn` — same shape for `opentelemetry` + `sentry_sdk`, asserts the observability line still starts with `⚠ `.

vm-vmanager-app-shaped consumers (and any consumer who BYO-ed a vault implementation) now see a clean doctor output. The `accept-with-rationale` row pattern in `MGF_COMMON_CATCHUP.md` for the `MGF-DOCTOR-01` warning can be dropped on next audit cycle.

---

#### <a id="PAPER-61"></a>`PAPER-61` — `mgf.common._request_id` has no public re-export; siblings that need `REQUEST_ID_HEADER` or `generate_request_id` are forced into a closed-box-leak carve-out

| Field | Value |
|---|---|
| Status | ✅ SHIPPED |
| Severity | 🟢 Nice-to-have |
| Submitter | mgf-django (2026-05-25, surfaced during the mgf-common 0.37 → 0.39 catch-up) |
| Submitted | 2026-05-25 |
| Decision | accept the paper's "Suggested shape" (5 names: `REQUEST_ID_HEADER`, `current_request_id`, `generate_request_id`, `set_request_id`, `reset_request_id`); the raw `_REQUEST_ID_VAR` ContextVar stays private — siblings go through the `set_` / `reset_` wrappers per the get/set discipline argument in the original paper |
| Decided on | 2026-05-25 |
| Scheduled for | next federation release |
| Shipped in | `src/mgf/common/_request_id.py` — `_generate_request_id` renamed to `generate_request_id`, `set_request_id` / `reset_request_id` wrapper helpers added (Token-returning), module docstring rewritten to call out the public surface lives at `mgf.common.` top level. `src/mgf/common/__init__.py` re-exports all five names + adds to `__all__`. `PUBLIC_API.md` documents each at `**stable** (v0.40.0)` with the PAPER-61 cross-reference. `tests/unit/test_request_id.py` pins the public-surface contract (4 test classes, 9 tests: importability from top-level `mgf.common`, UUID-shape generation, empty-string-outside-request default, set/reset cycle correctness incl. nesting + async-child propagation, closed-box-leak-closed regression guard). Siblings (`mgf-django`, `mgf-fastapi`) migrate off the `# noqa: PLC2701` carve-out in matching commits — both drop the `_request_id` reach-in and import from `mgf.common` instead |

**Concern.** `mgf.common._request_id` defines `REQUEST_ID_HEADER` (the canonical `X-Request-Id` header name), `_REQUEST_ID_VAR` (the `ContextVar[str]` carrying the request-id), and `_generate_request_id` (the UUID-style generator). All three are needed by middleware-style siblings — `mgf-django._middleware.MgfRequestIdMiddleware`, `mgf-fastapi.RequestIdMiddleware` — to propagate request ids consistently with mgf-common's logging + tracing wiring. **None of the three has a public re-export.** The siblings reach into `mgf.common._request_id` directly, violating AP-14 (consumers MUST NOT import `mgf.common._*`), and `mgf-common catch-up`'s closed-box-leak audit fires on every catch-up cycle.

**Why it matters.** Closed-box leaks are an L1-MUST violation. The catch-up audit is correct to flag them — but the federation has no public surface to migrate to. The result: every middleware-shape sibling carries a documented carve-out in its MGF_STANDARDS_CONFORMANCE.md saying "accept-with-rationale: PAPER-61." Two siblings do this today (mgf-django, mgf-fastapi); a third (a future `mgf-litestar` or `mgf-flask`) would do the same. **The right cut is one re-export decision in mgf-common, not N copies of the same carve-out across siblings.**

**Concrete evidence.** mgf-django session 2026-05-25, during the 0.37 → 0.39 catch-up:

- `mgf-common catch-up` audit reported three closed-box leaks: `src/mgf/django/_apps.py:71` (`from mgf.common._lifecycle import bootstrap`), `src/mgf/django/_middleware.py:14` (`from mgf.common._request_id import _REQUEST_ID_VAR, REQUEST_ID_HEADER, _generate_request_id`), `src/mgf/django/_middleware.py:207` (`from mgf.common._lifecycle import current_context`).
- Two of the three migrate cleanly to the public surface: `bootstrap` and `current_context` are both in `mgf.common.__all__`.
- The third is the request-id trio — no public re-export exists. Filed this PAPER + left the import in mgf-django with an inline `# NOTE — closed-box-leak carve-out` comment + a `# noqa: PLC2701` suppression citing this PAPER.

**Suggested shape.** Promote three names to `mgf.common` public surface:

- **`REQUEST_ID_HEADER`** — the canonical header name (`"X-Request-Id"`). String constant; trivially stable.
- **`generate_request_id() -> str`** — public renaming of `_generate_request_id` (drop the leading underscore). Pure function; no state.
- **`get_request_id() -> str | None`** — read the current request-id from the ContextVar. Naming convention `get_*` matches `current_context` (the lifecycle-public sibling).

The `ContextVar` itself (`_REQUEST_ID_VAR`) should stay private — exposing the raw ContextVar invites consumers to bypass the get/set discipline. Provide `get_request_id` / `set_request_id` instead. Once these land, the middleware siblings drop the `# noqa: PLC2701` and migrate to the public surface in one mechanical commit each.

**Cost if deferred past v1.0.** Low — the carve-out is documented and the audit-row pattern is established. But each middleware-shape sibling re-discovers it independently, and the conformance-tracker entries propagate the same boilerplate forever. The fix is one PR in mgf-common (~30 lines: re-export + tests + PUBLIC_API.md entry); the cleanup across siblings is also small (~3 lines per sibling).

**Cross-references.** Pairs with PAPER-21 (PlasmaMapper's similar private-import leak — `from mgf.common._lifecycle import bootstrap` — which was closed when mgf-common exported `bootstrap` publicly). The same pattern applies here: an internal name that's actually load-bearing for federation siblings needs to graduate.

**Decision rationale.** Same-day triage + ship. The paper's analysis was already complete — three concrete failure cases documented (`mgf-django` middleware, `mgf-fastapi` middleware, "future `mgf-litestar` / `mgf-flask`"), one cross-reference to a closed precedent (PAPER-21's `bootstrap` graduation), and a specific public-surface proposal that didn't require federation-wide debate. Combining accept + ship in one session removed an entire round of "wait for triage, wait for cleanup PRs" churn. The wrapper-functions choice (`set_request_id` / `reset_request_id` Token-returning) over raw-ContextVar exposure follows the get/set-discipline argument the paper itself made — keeps the implementation freedom to swap the ContextVar shape later (e.g. for a `(rid, span_id)` tuple) without consumers seeing the change.

**Retrospective.** Shipped same-day (2026-05-25) inside mgf-common's next-release window. Five new public names; one private name (`_generate_request_id` → `generate_request_id`) renamed in the process. The implementation is ~25 lines of additive code in `_request_id.py` + ~10 lines of re-exports in `__init__.py` + 9 unit tests + the PUBLIC_API.md rows. Net consumer-side cleanup: each sibling drops the 8-line `# NOTE — closed-box-leak carve-out (mgf-common PAPER-61)` block + the `# noqa: PLC2701` annotation, and the import switches from `from mgf.common._request_id import (...)` to `from mgf.common import (...)`. 1675 tests pass on mgf-common; 41 (alembic) + 54 (django) + 152 (fastapi) on the affected siblings. One subtle wrinkle worth recording: pytest's mock-patch sites now have to patch `mgf.common.set_request_id` (the public re-export) when intercepting from within tests, not `mgf.common._request_id._REQUEST_ID_VAR.set` — same lookup-where-it's-used pattern that surfaced during the bootstrap/current_context PAPER-21 migration.

---

#### <a id="PAPER-60"></a>`PAPER-60` — `mgf.common.observability.excepthook` raises `ValueError: I/O on closed file` during interpreter / pytest shutdown when stderr is already closed

| Field | Value |
|---|---|
| Status | ✅ SHIPPED |
| Severity | 🟡 High |
| Submitter | vmanager (vm-vmanager-app branch `mgf-vm-extraction`, ~2026-05-24) |
| Submitted | 2026-05-24 |
| Decision | accept both shapes (A + B together) — the helper is short and combining them costs nothing while A alone wouldn't close the `sys.is_finalizing()` window the paper identifies as the most fragile |
| Decided on | 2026-05-24 |
| Scheduled for | next federation release |
| Shipped in | `src/mgf/common/observability/excepthook.py::_safe_emit` (new module-level helper using `sys.is_finalizing()` short-circuit + `try/except (ValueError, OSError)` around `log.error` + `traceback.print_exception` fallback to `sys.__stderr__` + last-resort swallow); both `excepthook.py` and `threading_excepthook.py` route their `log.error(...)` call through `_safe_emit`. Tests in `tests/unit/observability/test_excepthooks.py::TestSafeEmitPaper60` pin all four failure modes (logger-raises-ValueError, logger-raises-OSError, finalizing-skips-logger, double-failure-still-swallowed) |

**Concern.** `mgf/common/observability/excepthook.py:65` calls `log.error("unhandled exception", ..., exc_info=...)` from the installed `sys.excepthook` / `threading.excepthook` / `asyncio` handlers. When the handler fires during interpreter teardown (or during pytest's late-cleanup phase, e.g., `_pytest.unraisableexception.collect_unraisable`), the logger's underlying stream can already be closed — the logging module then raises `ValueError: I/O operation on closed file.` *inside* the excepthook itself. The original unraisable exception is buried; the visible failure becomes the cascade from the excepthook, which is much harder to triage.

Concrete trace (vmanager `tests/unit/test_cli_alert.py::TestAlertCheck::test_check_dry_run_does_not_mutate_state`, Python 3.13.12, pytest 8.x):

```
--- Logging error ---
Traceback (most recent call last):
  File ".../logging/__init__.py", line 1154, in emit
    stream.write(msg + self.terminator)
ValueError: I/O operation on closed file.
Call stack:
  File ".../mgf/common/observability/excepthook.py", line 65, in hook
    log.error("unhandled exception", ...)
```

The trigger was unrelated (a sqlite3 `ResourceWarning` from a real consumer bug — fixed in vm-vmanager-app commit; see retrospective below), but the excepthook's behaviour during that bug's surfacing made diagnosis substantially harder than it needed to be.

**Why it matters.** Every consumer of `mgf.common.bootstrap()` installs this excepthook. Under Python 3.13 (where unraisable-exception handling is stricter) and modern pytest (which routes unraisable warnings through `collect_unraisable` during teardown when stderr may already be closed), the cascade is reproducible on any test that triggers an unraisable warning during shutdown. The excepthook is the project's last line of defence for unhandled exceptions — it should never become a new failure source, particularly when its job is to surface the *original* one.

Defensive coding inside an excepthook is non-negotiable: by definition the excepthook fires when something has already gone wrong; it MUST NOT make things worse. Per `mgf-common` standards' own EH-04 spirit ("every entry point MUST install a top-level handler") — the handler being brittle defeats the purpose of the rule.

**Concrete evidence.** Path: `mgf/common/observability/excepthook.py:65` (the `log.error(...)` call inside the installed `sys.excepthook` body). Trigger context: any unraisable warning during interpreter or pytest shutdown — including the now-stricter Python 3.13 unraisable-exception flow. Reproducer (pre-fix, on the affected vmanager commit, Python 3.13): `uv run pytest -x -q tests/unit/test_cli_alert.py::TestAlertCheck::test_check_dry_run_does_not_mutate_state` — produces a `pytest.PytestUnraisableExceptionWarning` followed by the cascade above.

**Suggested shape.** Two complementary changes inside `excepthook.py`:

- **(A — minimum)** Wrap the `log.error(...)` call in a narrow `try / except (ValueError, OSError)` and fall back to writing directly to `sys.__stderr__` (the unredirected original stderr, which the logging module's stream-closure cannot affect). If even that fails, swallow silently — never let the excepthook raise. Cost: ~6 lines.

- **(B — additionally)** Detect interpreter shutdown via `sys.is_finalizing()` at the top of the hook and skip the rich `log.error` formatting (which can trigger arbitrary handler I/O), going straight to the `sys.__stderr__` fallback. Cost: ~2 lines. Avoids the cascade entirely in the most fragile window.

A reusable helper `_safe_emit(msg, exc_info)` in the same module localises the defensive write. The shape:

```python
def _safe_emit(msg: str, exc_info) -> None:
    try:
        if not sys.is_finalizing():
            log.error(msg, exc_info=exc_info)
            return
    except (ValueError, OSError):
        pass
    # Fallback: write directly to the unredirected stderr.
    try:
        traceback.print_exception(*exc_info, file=sys.__stderr__)
    except Exception:
        pass  # excepthooks never raise
```

**Cost if deferred past v1.0.** Medium. The behaviour is consistent across every consumer; once observed it costs every consumer ~30 minutes of misdirected diagnosis on the first occurrence (we have one instance documented here; more are likely as the federation grows and Python 3.13 adoption increases). The fix is purely additive — no API change, no behavior change in the happy path. Deferring carries the diagnostic cost forward.

**Related downstream change.** vmanager closed the *trigger* (an `sqlite3.Connection` leak in its `_extract_history` CLI helper that violated DP-13) in the same session. That fix removes the cascade from vmanager's tests but does not address the underlying excepthook brittleness, which will surface again whenever any consumer hits an unraisable warning during shutdown.

**Decision rationale.** Both shapes (A — narrow `try/except (ValueError, OSError)` + `sys.__stderr__` fallback; B — `sys.is_finalizing()` short-circuit) were accepted together. Each on its own leaves a window: A handles closed-stream cascades from active-but-broken loggers but still walks the rich formatting path during teardown (which can re-trip arbitrary handler I/O); B handles teardown but doesn't protect the normal-runtime path where the logger is wired but the stream happens to be closed. Combined, they cost ~25 lines for a helper plus two single-line call-site swaps and close every brittleness mode the paper identified. The helper is centralised in `excepthook.py` and re-used from `threading_excepthook.py` so both hooks pick up the protection from one definition; this avoids the easy footgun of fixing only `sys.excepthook` and leaving the threading variant exposed.

**Retrospective.** Shipped same-day (2026-05-24) inside `mgf-common` 0.38.x → next release. The fix is purely additive — the happy-path (logger-stream-open, interpreter-not-finalising) behaviour is byte-identical to the previous implementation, so the existing `test_excepthook_writes_crash_report_for_runtime_error` and `test_threading_excepthook_writes_crash_report_for_runtime_error` regression-guards pass unchanged. Four new pins under `TestSafeEmitPaper60` exercise the failure modes; one minor wrinkle worth recording — pytest's `capsys` doesn't capture writes to `sys.__stderr__` (only to `sys.stderr`), so the fallback-was-used assertions had to patch `sys.__stderr__` with an `io.StringIO` directly. Federation-wide impact: every consumer of `mgf.common.bootstrap()` gains the protection transparently on upgrade; vmanager's specific `_extract_history` sqlite3 leak that triggered the discovery is closed independently in its branch (paper documents it as the "related downstream change"). No deprecation, no API change, no migration for consumers. Re-opens only if a third brittleness mode surfaces.

---

#### <a id="PAPER-50"></a>`PAPER-50` — LG-12 perf-test budget (100 µs/record) is brittle across hardware

| Field | Value |
|---|---|
| Status | ⏸ DEFERRED |
| Severity | 🟢 Nice-to-have |
| Submitter | vm-vmanager-app (commit [`8ec0075`](https://codeberg.org/magogi-admin/vm-vmanager-app/commit/8ec0075)) |
| Submitted | 2026-05-18 |
| Decision | DEFERRED (2026-05-27) — the 2026-05-27 vm-vmanager-app pin walk (REL-10 cohort follow-up) skipped `tests/unit/test_logging_perf.py` at module level pending a bootstrap()-based rewrite. That skip reaffirms the brittleness this PAPER predicts — the per-record budget was tied to a removed API (`setup_logging`) and never had a hardware-portable shape. Upstream mgf-common's perf suite (`tests/unit/observability/`) covers LG-12 for the federation-owned stack; vm-vmanager-app's consumer-side guard becomes meaningful when vm-vmanager-app grows its own formatter / redactor / handler. Full per-hardware calibration is post-v1.0 work; the test-rewrite is consumer-side. |
| Decided on | 2026-05-27 |
| Scheduled for | — |
| Shipped in | — |

**Concern.** [LG-12 in `docs/standards/LOGGING.md`](https://codeberg.org/magogi-admin/mgf-common/src/branch/main/docs/standards/LOGGING.md) says "≤ 100 µs per record" as an absolute number. Consumer pin-tests (e.g., vm-vmanager-app's `tests/unit/test_logging_perf.py::test_setup_logging_emission_budget`) bake the threshold in as a hardcoded 1.0 s budget for 10,000 records. The test message asserts the only causes of failure are "synchronous remote ship, per-record I/O, or a formatter regression." In reality, the test ALSO fires on slower-than-"modern-laptop" hardware regardless of code.

**Why it matters.** During vm-vmanager-app's 0.34 → 0.38 catch-up, the LG-12 test failed at ~130 µs/record. Initial assumption: a 0.38 regression. Diagnosis required reverting the pin and re-running 3× under 0.34 — same ~130 µs result. The failure was the dev hardware, not the code. Half an hour spent before catch-up could proceed. Every consumer who hits this once pays the same diagnostic cost; the test message actively points away from the real cause.

**Concrete evidence.** vm-vmanager-app session 2026-05-18 — six runs across the pin-bump diagnostic, all between 1.30 s and 1.53 s (130–153 µs/record). The audit text in [vm-vmanager-app's `STANDARDS_COMPLIANCE.md`](https://codeberg.org/magogi-admin/vm-vmanager-app/src/branch/main/docs/STANDARDS_COMPLIANCE.md) (LG-12 row) says "~0.5 s on a modern laptop (≈50 µs/record, 2× under budget)" — accurate on the reference machine, off by 2.6× on the current one. Same code path. Same `JOURNAL_STREAM=` / `INVOCATION_ID=` clearing tried with no effect.

**Suggested shape.** Three options:

- **Approach A — ratio-based budget**. LG-12 restated as "logging emission MUST be ≤ N× the noop `logging.Logger.handle()` no-handler emission time on the same machine." The consumer's test computes the baseline once at setup, then asserts the JSON-formatter + rotating-file-sink path stays within N× of it. Self-calibrating to hardware. Most-disruptive; requires consumer test rewrite.
- **Approach B — budget-discovery script**. `mgf-common bench --record-baseline` writes `<consumer>/.mgf-common-perf-baseline.toml` with the machine's measured noop emission time + a recommended LG-12 budget scaled up to N× that. Consumers commit the baseline file; the test reads from it. Re-run on hardware migrations.
- **Approach C — explicit hardware-baseline marker**. The reference test ships with `@pytest.mark.requires_perf_baseline` and skips with a clear "this machine ran the noop at X µs; LG-12 budget needs ≥Y µs/record headroom; mark slow-host CI" message. Least architectural disruption; still surfaces the cause clearly when the test does fire.

Approach A is the cleanest; Approach C is the easiest to ship as a one-cycle fix while A is being designed.

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

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

---

#### <a id="PAPER-51"></a>`PAPER-51` — Deprecation-cascade detection: consumers discover deprecated names only at test-collection time after `uv sync`, not before the pin bump

| Field | Value |
|---|---|
| Status | ✅ SHIPPED (wording fix) — full audit-row deferred to v1.x |
| Severity | 🟡 Papercut |
| Submitter | iosRecovery / figrecovery (commit [`50ac5b8`](https://codeberg.org/magogi-admin/iosRecovery/commit/50ac5b8)) |
| Submitted | 2026-05-18 |
| Decision | accept partial — the "since v0.39.0" stale-string bug fixed in the v1.0 catch-up bundle; the full catch-up audit-row for deprecated-name use lands in v1.x |
| Decided on | 2026-05-19 |
| Scheduled for | partial shipped (v1.0); full Approach A deferred to v1.x |
| Shipped in | wording fix at `src/mgf/common/exceptions/__init__.py:121` ("deprecated since v0.38.0 (alias-removal in v2.0)") + 4 sibling docstring corrections in same file + test update at `tests/unit/test_n818_deprecation_aliases.py:60` |

**Concern.** When mgf-common ships a deprecation alias (e.g. the PEP-8 N818 exception renames in 0.38: `ProcessNonZeroExit` → `ProcessNonZeroExitError`), consumers don't find out their source uses the old name UNTIL they (a) bump the pin, (b) `uv sync`, and (c) run pytest — at which point `MgfDeprecationWarning` raises through pytest's `filterwarnings = ["error"]` at COLLECTION time, breaking every test that transitively imports the affected module. The signal is in the right place (the warning DOES fire) but it lands AFTER the maintainer has already committed to the pin bump and re-installed the dep tree. Reversing course at that point is awkward.

**Why it matters.** Catch-up's existing audit knows the consumer's installed mgf-common version + the latest available. It could ALSO know which names are deprecated in the next release window and which call sites in the consumer's source would trip them. Surfacing this BEFORE the operator bumps the pin would turn "surprise test collection failure" into "predicted rename list with line numbers." Same data; better timing. For iosRecovery the cascade was: pin bump → `uv sync` → all six tests/unit/platforms/* test files fail at collection → diagnose → grep for 5 callsites across 6 modules → sed-rename → re-run. ~20 minutes; for a larger consumer (vm-vmanager-app has ~30 `mgf.common.exceptions` imports across 8 files) the manual rename phase would dominate.

**Concrete evidence.** iosRecovery session 2026-05-18, the 0.37 → 0.38 catch-up wave:

- `uv sync` succeeds (pin bump + new mgf-common installed).
- `uv run pytest` fails at collection with:
  ```
  E   mgf.common._warnings.MgfDeprecationWarning:
  E   `mgf.common.exceptions.ProcessNonZeroExit` is deprecated since
  E   v0.39.0; use `ProcessNonZeroExitError` instead. The alias will
  E   be removed in v2.0.
  ```
  (13 collection errors across 6 test files — every one importing platforms/* or cli/__main__.py.)
- Six source files needed mechanical rename: `cli/__main__.py`, `exceptions.py`, `platforms/usb.py`, `platforms/systemd.py`, `platforms/binaries.py`, `core/health/bundle.py`.
- Catch-up reports zero findings BEFORE the bump (the deprecated-name use is invisible — catch-up audits the pyproject + the federation contract, not the source).

The "since v0.39.0" wording in the deprecation message is also a separate confusion data point — mgf-common is at 0.38.0, not 0.39.0. The deprecation actually shipped IN 0.38.0; the wording reads like a future warning.

**Suggested shape.** Approach A vs Approach B:

- **Approach A — catch-up audits source for deprecated-name use.** Add a new audit row: "source uses deprecated mgf.* names." Catch-up scans `src/` (or `src_modules` from pyproject) for imports/references to any name on mgf-common's deprecated registry; reports `⚠️` with one line per call site. Operator sees the list BEFORE bumping the pin and decides whether to bundle the rename in the same PR. Mechanical to implement; mgf-common already maintains the rename registry inside `mgf.common.exceptions.__getattr__` (used to emit the warning at import time). Expose the registry as data (e.g., `mgf.common.deprecations.entries() -> dict[str, str]`) for catch-up to read.
- **Approach B — `mgf-common rename-deprecated --dry-run` / `--apply`.** A separate subcommand that scans + proposes / applies the renames as a unified patch. Operator runs it after the pin bump but before commit, gets `git diff`-equivalent output of every proposed edit, accepts or rejects per site. Higher implementation cost (need a real Python AST-aware rewriter to avoid string-match false positives in docstrings + comments) but eliminates the manual sed phase entirely.

**Recommendation:** A as the immediate ship (gets the signal to the right place), B as a follow-up if A's `⚠️` rows are too noisy in practice. Together they collapse the "surprise → diagnose → rename → re-sync" loop into "predicted-rename list → patch → commit + sync."

(Also worth fixing in passing: the "since v0.39.0" wording in the deprecation message itself. The deprecation shipped in 0.38; the alias-removal will be in 2.0 per the message. Both versions exist in the same string. Suggested: "deprecated since v0.38.0 (alias-removal in v2.0)" — present-tense the actual-shipped version.)

**Decision rationale.** Split the work into two phases:
**(now, v1.0 catch-up bundle)** ship the wording fix — the
"since v0.39.0" stale string is misleading every consumer who
hits the deprecation right now. **(v1.x)** the full Approach A
(catch-up audits source for deprecated-name use) lands after
v1.0 — the v1.0 wave is tight enough already, and the data API
piece is already 90% done (the registry exists at
`exceptions/__init__.py:100`; only the export + the audit-row
wiring need to land). Approach B (full codemod sub-command)
stays parked unless A's ⚠️ rows turn out too noisy.

**Retrospective (partial, wording fix).** Five edits in
`src/mgf/common/exceptions/__init__.py`: line 27 (`renamed at
v0.39.0` → `at v0.38.0`), line 37 (`v0.39.0 → v2.0` → `v0.38.0 →
v2.0`), line 94 (registry comment, same fix), line 117 (docstring,
same fix), and line 121 (the warning string consumers actually
see — body reshaped to "deprecated since v0.38.0 (alias-removal
in v2.0)" per the submitter's wording suggestion).
`tests/unit/test_n818_deprecation_aliases.py` updated to match.
All 26 N818 deprecation-alias tests pass; full federation suite
1639 / 0.

**v1.x follow-up.** The full Approach A audit-row in catch-up
needs:
1. Expose `_N818_DEPRECATED_ALIASES` (or a superset) as
   `mgf.common.deprecations.entries() -> dict[str, str]`
   public API (~5 LOC; the dict already exists).
2. New audit-row check in `_catch_up.py` that scans `src/` (or
   `src_modules` from pyproject) for imports / references to any
   name on the deprecated registry; reports `⚠️` with one line
   per call site BEFORE the pin bump.
3. Cross-link from the federation-sync message
   (PAPER-57) so consumers know to expect the new audit row.

When Q-2 ships (N818 renames per `v1_0_handoff.md §3`), the
registry grows by 12 entries and the audit-row becomes
proportionally more valuable. That's the natural trigger to
prioritise this v1.x follow-up.

---

#### <a id="PAPER-52"></a>`PAPER-52` — `mgf-common catch-up` onboarding-output text hard-codes the pin window; templating from `__version__` would track federation drift automatically

| Field | Value |
|---|---|
| Status | ✅ SHIPPED |
| Severity | 🟢 Nice-to-have |
| Submitter | iosRecovery / figrecovery (commit [`50ac5b8`](https://codeberg.org/magogi-admin/iosRecovery/commit/50ac5b8)) |
| Submitted | 2026-05-18 |
| Decision | accept (template the pin from `mgf.common.__version__` at call time; shared helper used by PAPER-57 federation-sync) |
| Decided on | 2026-05-19 |
| Scheduled for | shipped in-tree (v1.0 catch-up bundle) |
| Shipped in | `src/mgf/common/cli/_init_project.py::_render_pyproject_snippet` now consumes `_version_pin.current_pin_window()`; tests in `tests/unit/cli/test_init_project.py::TestPyprojectSnippet` switched from literal-pin assertions to live-helper assertions |

**Concern.** The catch-up tool's onboarding output (the "Suggested per-project next steps" block) includes a literal pin window: `Bump mgf-common>=0.37,<0.38 in pyproject`. When mgf-common itself moves to 0.38, the suggestion is wrong — it tells operators to pin to a window that excludes the current release. The audit's drift report (`⚠️ STALE: declared >=0.37,<0.38 but current ... is 0.38.0`) correctly identifies the gap, but the very next paragraph of the same tool's output tells the operator to re-create the gap.

**Why it matters.** Federation onboarding is exactly when the operator is most likely to copy the suggested commands verbatim. A new consumer reading the catch-up footer today gets a pin window that, by the time their PR lands, is one minor behind. They then have to re-run catch-up, see the drift, and bump again. The friction is small per occurrence but it's encountered by EVERY new consumer, and the wrong version-in-text is more confusing than just leaving the version out.

**Concrete evidence.** Operator pasted the onboarding block twice this session (2026-05-18). Both pastings showed `>=0.37,<0.38` while mgf-common was at 0.38.0. The operator's second pasting was specifically to point at the version-mismatch.

**Suggested shape.** Template the version window from the running `mgf-common.__version__`:

```python
# In the onboarding-text generator:
import mgf.common
v = mgf.common.__version__   # e.g. "0.38.0"
parts = v.split(".")
window = f">={parts[0]}.{parts[1]},<{parts[0]}.{int(parts[1])+1}"
# → ">=0.38,<0.39"
```

The drift-report block already uses the live `__version__` (the `STALE: ... current ... is 0.38.0` message). The onboarding-text block should consume the same source.

Optional follow-up: catch-up could also emit a `--bump-pin` flag that performs the mechanical pyproject edit (find the `mgf-common>=X,<Y` line; replace; print the diff). That's adjacent to [PAPER-20]'s concern (which is about whether catch-up uses installed vs latest-PyPI as the bump target). Mention here for context but track separately.

**Decision rationale.** Accept as suggested. The fix is a small
delta inside `_render_pyproject_snippet`: replace the hardcoded
`>=0.31,<0.32` strings with `f'"mgf-common{current_pin_window()}"'`.
The same helper feeds PAPER-57's federation-sync subcommand so
the federation has ONE source of truth for the pin shape.

**Retrospective.** Shipped via a new shared helper module
`src/mgf/common/cli/_version_pin.py` exposing
`pin_window_for_version(version: str) -> str` and
`current_pin_window() -> str`. `_init_project.py` consumes the
latter to template the mgf-common pin in all five per-kind
dependency lists (cli, fastapi, django, library, service). Tests
switched from literal-pin assertions (`"mgf-common>=0.31"`) to
live-helper assertions (`f"mgf-common{current_pin_window()}"`),
so the test survives every future release without re-edit.

**Out-of-scope sibling-pin templating.** This commit keeps the
sibling pins (`mgf-fastapi>=0.2`, `mgf-http>=0.1`, etc.) as their
literal values. Each sibling has its own independent semver and
there is no cross-package version registry in mgf-common today.
Templating sibling pins is a separate problem with its own
solution shape; track as v1.x follow-up if a second consumer
flags it.

---

#### <a id="PAPER-53"></a>`PAPER-53` — `mgf-common init-consumer <name> <kind>` would template a new sibling/consumer skeleton in one command

| Field | Value |
|---|---|
| Status | ⏸ DEFERRED |
| Severity | 🟢 Nice-to-have |
| Submitter | iosRecovery / figrecovery (commit [`50ac5b8`](https://codeberg.org/magogi-admin/iosRecovery/commit/50ac5b8)) |
| Submitted | 2026-05-18 |
| Decision | DEFERRED (2026-05-27) — `mgf-common init-project` covers the consumer-app shape; the sibling-bootstrap path is partly covered by [[sibling-rename]] recipe (PAPER-79-A shipped in 0.41.6) + the cohort-release-wave recipe. Six new siblings extracted in 2026-05 (mgf-vm-core/-sdk/-libvirt/-firecracker/-forensics/-interchange) used a manual scaffold; the federation L2-floor walk-up surfaced no friction strong enough to justify a CLI helper yet. Composes naturally with PAPER-79 Approach C (federation registry) when that lands. |
| Decided on | 2026-05-27 |
| Scheduled for | — |
| Shipped in | — |

**Concern.** Bootstrapping a new federation sibling (today's case: mgf-usb) requires hand-creating a substantial scaffold from templates scattered across mgf-common's `docs/recipes/`: pyproject.toml (with the standard four-gate `[tool.pytest.ini_options].addopts` + `[tool.coverage.report].fail_under` + `[tool.ruff.lint].select` + `[tool.mypy].strict` blocks), the universal-five docs (README / LICENSE / CHANGELOG / SECURITY / FEEDBACK), DOC-14 / DOC-15 trackers, `.gitignore`, `.editorconfig`, `src/mgf/<name>/__init__.py`, `tests/{unit,integration,hardware}/__init__.py`, `tests/conftest.py`, and the mgf-common pin in dependencies. Catch-up subsequently flags every missing artifact, but only AFTER the operator has manually written everything else.

**Why it matters.** Each new sibling pays the same scaffolding tax. The mgf-usb scaffolding commit (`c0dd109`) was 17 files / 1,111 lines of work that was almost entirely template-driven — most of the lines are boilerplate from mgf-common's recipes that the operator copied + adapted by hand. A consumer-init command that takes `--name <name> --kind <library|cli|service|fastapi|django>` and emits the exact same skeleton in one invocation would compress that ~30-minute scaffolding session into ~30 seconds, with the added benefit that the template is centrally maintained — future federation-contract additions automatically land in new projects.

**Concrete evidence.** mgf-usb scaffolding commit:
- `pyproject.toml` (178 lines) — every line either standard ruff/mypy/pytest/coverage config OR sibling-typical metadata (namespace package, `mgf-common>=X,<Y` pin, dev extras).
- `LICENSE` — verbatim MIT.
- `README.md` (116 lines) — boilerplate sections + project-specific intro.
- `STARTHERE.md` (77 lines) — boilerplate "for consumers / contributors / operators" structure + project-specific content.
- `CHANGELOG.md` (37 lines), `CONTRIBUTING.md` (61 lines), `SECURITY.md` (43 lines), `FEEDBACK.md` (87 lines) — all template-shaped, lightly customised.
- `.gitignore`, `.editorconfig`, `tests/conftest.py`, four `__init__.py` files — pure boilerplate.
- `src/mgf/usb/__init__.py` (18 lines) — boilerplate docstring + `__version__`.

That's ~15 of 17 files where the operator's contribution is the project name + 2-3 lines of project-specific intro. The remaining 2 files (DESIGN.md, smoke test) are genuinely project-specific.

**Suggested shape.** Approach A vs Approach B:

- **Approach A — separate subcommand.** `mgf-common init-consumer --name mgf-usb --kind library --license MIT --visibility codeberg-public` writes the full skeleton to a target dir (defaults to `./<name>/`). Templates live in `mgf-common/docs/recipes/init-consumer/` with `{{name}}` / `{{kind}}` / `{{license}}` placeholders that a small renderer fills. Idempotent — re-running on an existing dir is a no-op unless `--force` is passed. Catch-up immediately reports clean against the new project.
- **Approach B — extend `catch-up --apply` with a `--create-missing-artifacts` flag.** When catch-up audits a project where MANY required artifacts are missing (e.g. fresh `git init` + empty pyproject), the same `--apply` flow could create them from templates. Closer to catch-up's existing scope; downside is operator confusion ("did I just create a project or fix a project?").

Approach A is cleaner — explicit subcommand, explicit intent. Approach B feels like overloading catch-up's audit semantics.

Cross-reference: this paper is the cornerstone-side complement to the consumer-side onboarding text that PAPER-52 wants to fix. PAPER-52 makes the text correct; PAPER-53 makes the cornerstone capable of doing what the text says.

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

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

---

### 💭 Aspirational

#### <a id="ASPIRE-01"></a>`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/design/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/design/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/design/multi_tenancy.md`](docs/design/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.

---

#### <a id="ASPIRE-02"></a>`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/inprogress/strategic_review.md`](docs/inprogress/strategic_review.md#priority-1--get-a-second-independent-consumer) Priority 1 marked **✅ ACHIEVED** (2026-05-05). (2) [PAPER-10](#PAPER-10) 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) 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/archive/v0.18.0.md`](docs/cutover/archive/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/inprogress/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` (vm-vmanager-app) +
   `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**.
vm-vmanager-app 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 vm-vmanager-app'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 vm-vmanager-app 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: vm-vmanager-app-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.)

---

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

| Field | Value |
|---|---|
| Status | ✅ SHIPPED (incremental — README; per-recipe promotion ongoing) |
| 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 | v0.20.0 — front-door navigation page at [`docs/standards/web/README.md`](docs/standards/web/README.md). Sections: *Why a per-kind subtree* (friction story + carve-out summary); *Read-in-order for a new web consumer* (7-step path: master index → CONFIGURATION §0a → framework-owned-logging → cloud-native-logging → ERROR_HANDLING with HTTP-01 → framework recipes → non-adoption principle); *Recipes index* (cross-cutting; web-domain) with canonical paths + paper closures; *Per-rule applicability for web consumers* (topic-doc-by-topic-doc summary of what applies / what's N/A or carved-out); *Promotion roadmap* (incremental checklist). Cross-link from `docs/standards/README.md` "Per-kind subtrees" pointer. Migration guide in [`docs/cutover/archive/v0.20.0.md`](docs/cutover/archive/v0.20.0.md#aspire-03--web-standards-subtree). **Per-recipe promotion roadmap** (each box is one future small PR): [x] `web/README.md` (v0.20.0) · [x] `docs/recipes/framework-owned-logging.md` (PAPER-25, v0.20.0) · [ ] `web/http-exceptions.md` (promote PAPER-24 cutover-guide content + `recipes/http.md` consumer-pattern) · [ ] `web/framework-config.md` (promote `CONFIGURATION.md` §0a inthewords worked example) · [ ] `web/stub-gaps.md` (django-stubs/DRF/Channels/Celery workaround library — contingent on inthewords' upstream contribution) · [ ] `web/adoption-walk.md` (canonical onboarding narrative). |

**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/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: vm-vmanager-app-shaped (Qt + libvirt)
    └── ...                      # parallel structure for desktop
                                 # consumers; populated as vm-vmanager-app'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.** Approach A (incremental). Approach B
(big-bang) is the same end-state shipped in one large PR — higher
review friction, same outcome. Incremental landing is also a
release-engineering experiment: v0.20.0 ships the front door;
per-recipe promotion lands as small PRs over subsequent releases,
tracked through this entry's Retrospective field. If the pattern
works, future structural ASPIRE entries can follow the same shape
(incremental landing with named milestones rather than one-shot
delivery).

**Retrospective.** v0.20.0 closes the structural commitment from
ASPIRE-02 — the "per-kind standards subtrees" 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. After two web consumers (inthewords + PlasmaMapper)
and three release rounds (v0.17 / v0.18 / v0.19), there was enough
crystallised content to justify the dedicated subtree. The README
shipped + PAPER-25's recipe shipped in the same release; per-recipe
promotion is named explicitly so consumers can see what's coming.
Status remains "shipped (incremental)" rather than fully closed
because the promotion roadmap has open boxes — each future small
PR ticks one box; this entry stays in this state until all are
checked.

---

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

| Field | Value |
|---|---|
| Status | ✅ SHIPPED |
| 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 | v0.20.0 — standalone principle doc at [`docs/standards/principles/non-adoption-is-valid.md`](docs/standards/principles/non-adoption-is-valid.md). Sections: *What this principle says* (core statement + three-outcome adoption framework: Adopted / Adopted with carve-out / Declined); *Three components* (conscious decline, documented rationale, feedback if generalizable); *When to non-adopt* (five concrete triggers — deployment-coupled contract, policy, framework architecture, consumer scale, cost vs alternative); *When NOT to non-adopt* (negative-space callout — six patterns that look like non-adoption but are cover for laziness, indefinite deferral, or refactor avoidance); *Worked examples* (inthewords' LOG-01 / BOOTSTRAP-01 pre-v0.19.0 + PlasmaMapper's "small consumer" scope discipline); *How to file the rationale* (project-side ADR, tracker entry, `FEEDBACK.md` paper — by durability; ADR template included). Cross-link from [`docs/standards/README.md`](docs/standards/README.md) "Documented non-adoption preserves conformance" paragraph. Migration guide in [`docs/cutover/archive/v0.20.0.md`](docs/cutover/archive/v0.20.0.md#aspire-04--documented-non-adoption-principle). |

**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.** Standalone file (recommended in the filing)
rather than inline in `docs/standards/README.md`. The principle is
meaty enough (the "When NOT to non-adopt" negative-space section
especially) to deserve its own page; inline would dilute both the
principle and the README. The principle is also positioned to be
referenced from sibling Magogi packages (mgf-apiprobe and future
mgf-* libraries) — having its own URL makes that cross-link cheap.

**Retrospective.** The framing behind every shipped carve-out
(LOG-01, CONFIG-01, BOOTSTRAP-01) was implicit until v0.20.0 named
it. Naming gives newer contributors cover to decline a primitive
when adoption would harm their context, AND nudges them to file
the rationale upstream so the carve-out can be generalised. The
"Magogi-foundation" framing means the principle is not mgf-common-
specific; it applies to every shared library the Magogi ecosystem
produces. inthewords' ADR-004 + ADR-006 + PlasmaMapper's "small
consumer" scope discipline are cited as worked examples — credit
lives in the doc's §"Worked examples". The doc is the structural
home for any future "documented non-adoption" filing.

---

#### <a id="ASPIRE-05"></a>`ASPIRE-05` — `mgf-common` as cross-language engineering-process source-of-truth

| Field | Value |
|---|---|
| Status | OPEN — 💭 Aspirational; post-v1.0 |
| Severity | 💭 Aspirational |
| Submitter | Maintainer (federation-shape design conversation, 2026-05-08) |
| Submitted | 2026-05-08 |
| Decision | post-v1.0 design conversation; kept OPEN as the 💭 inventory item. mgf-website-template (PAPER-30, shipped) is the first non-Python sibling and has L1 conformance — the first concrete data point for whether the standards bifurcate language-aware vs language-agnostic. Revisit at v1.0 once 2+ non-Python siblings exist. |
| Decided on | 2026-05-27 (status confirmed) |
| Scheduled for | post-v1.0 (long-horizon) |
| Shipped in | — |

**Concern.** `mgf-common` today has two stated identities: (a) a
Python infrastructure library, (b) the standards source-of-truth
for the Magogi engineering discipline (`docs/standards/`). These
are bundled by accident of history — the standards were colocated
with the Python library that exemplifies them.

If the Magogi ecosystem grows a non-Python project (e.g.,
`mgf-website-template` — Astro/TypeScript per PAPER-30 below), the
standards source-of-truth role MUST extend beyond Python while the
library role does not. The two responsibilities pull apart.

**Why it matters.** The first non-Python Magogi project will reveal
this seam. Decisions to make:

1. Does the standards repo split from the Python library? (e.g.
   `mgf-standards/` repo holds `docs/standards/`; `mgf-common/`
   holds the Python implementation.)
2. Or does `mgf-common` keep both roles, with non-Python projects
   referencing standards by URL into mgf-common's repo?
3. Or does `mgf-common` get language-aware tooling — e.g.
   `init-project --kind website` scaffolds an Astro project with
   a CLAUDE.md that inlines the language-agnostic Magogi rules?

**Concrete evidence.** The `--kind website` design conversation
(2026-05-08) produced PAPER-30 (cookiecutter sibling repo for
Astro marketing sites). That sibling will need to reference the
language-agnostic subset of `docs/standards/`: DESIGN_PRINCIPLES,
SECURITY_STANDARD §subsets, parts of TESTING and API_DESIGN.
The Python-specific subset (CONFIGURATION pydantic-settings,
LOGGING stdlib + JSON formatter, ERROR_HANDLING typed exception
hierarchy) doesn't apply.

**Suggested shape.** Defer the structural decision until two or
more Magogi non-Python projects exist (the "second-consumer
arrival" promotion bar). Until then:

- mgf-common keeps both roles as today.
- Non-Python siblings reference `docs/standards/` by URL.
- The cross-cuts (DESIGN_PRINCIPLES, SECURITY_STANDARD applicable
  subsets) are not duplicated; the non-Python sibling's CLAUDE.md
  links to mgf-common's URLs.

When a second non-Python sibling exists, revisit:

- Option A — Split standards out: `mgf-standards/` is a separate
  repo; `mgf-common/` becomes a pure Python library that
  references the standards repo. Cleanest but largest move.
- Option B — Standards stay in mgf-common; mgf-common gets
  language-aware `init-project` for non-Python kinds. Standards
  repo keeps both roles but acknowledges them.
- Option C — Per-language standards subtrees in mgf-common:
  `docs/standards/python/`, `docs/standards/typescript/`, etc.
  Heaviest documentation overhead.

Recommended at decision time: Option A if the non-Python population
is large (3+); Option B if it's small (1-2).

**Cost if deferred past v1.0.** Low. v1.0 locks the Python public
API surface, not the standards-repo structure. The standards can
move OR stay; both options remain open.

**Cross-references.**
- `docs/design/federation.md` — the federation pattern (Python siblings).
- `docs/inprogress/federation_roadmap.md` — the v0.28 → v1.0 split plan
  (Python-only at this stage).
- PAPER-30 (mgf-website-template) — the first non-Python Magogi
  sibling and the trigger for re-evaluating ASPIRE-05.
- ASPIRE-06 — the v0.28-→-v1.0 federation split that this builds on.

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

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

---

#### <a id="ASPIRE-06"></a>`ASPIRE-06` — Federated package architecture: extract framework adapters into sibling packages by v1.0

| Field | Value |
|---|---|
| Status | ACCEPTED (staged across v0.28 — v0.31; lock at v1.0) |
| Severity | 💭 Aspirational |
| Submitter | Maintainer (federation-shape design conversation, 2026-05-08) |
| Submitted | 2026-05-08 |
| Decision | accept (per-minor extractions: mgf-fastapi v0.28, mgf-http v0.29, mgf-sqlalchemy + mgf-alembic v0.30, mgf-django v0.31; lock surface at v1.0; standards stay in mgf-common; sibling-domain concrete exceptions move with their sibling; tight-coupling-runs-adapter→core; consumers (PlasmaMapper / inthewords) face one rename per release) |
| Decided on | 2026-05-08 |
| Scheduled for | v0.28 / v0.29 / v0.30 / v0.31 / v1.0 |
| Shipped in | v0.28.0 (mgf-fastapi 0.1.0 — 2026-05-08), v0.29.0 (mgf-http 0.1.0 — 2026-05-09), v0.30.0 (mgf-sqlalchemy 0.1.0 + mgf-alembic 0.1.0 + mgf-fastapi 0.2.0 — 2026-05-10), v0.31.0 (mgf-django 0.1.0 — 2026-05-10). All four framework-adapter extractions shipped; v0.32+ stabilization window opens before v1.0 lock. |

**Concern.** `mgf-common` today contains both pure-Python core
infrastructure (AppError + bootstrap + observability + config +
settings + providers + process + cli + vault + versioned + etc.)
AND framework-specific adapters (`mgf.common.fastapi`,
`mgf.common.django`, `mgf.common.http`, `mgf.common.db`,
`mgf.common.alembic`). The two shapes have diverged enough that
co-locating them creates friction:

- **Surface size.** v0.26 + v0.27 added 7 new public names — all
  under `mgf.common.fastapi.*`. The web-stack surface is growing
  faster than the core.
- **Versioning collision.** A `HttpError` change in core requires
  a mgf-common minor; an `IpAllowlist` knob change ALSO requires
  a mgf-common minor. They don't evolve at the same rate but they
  ship at the same cadence.
- **Mental-model breadth.** A new consumer reading `mgf.common.*`
  reads through 15+ submodules; reducing to ~10 pure-Python
  submodules with clearly-separate framework-adapter siblings
  shrinks the cognitive load.
- **Closed-box hygiene.** Today the `[fastapi]`, `[django]`,
  `[http]`, `[db]`, `[alembic]` extras are the ONLY barrier
  between a CLI consumer and the framework deps. A sibling-
  package boundary makes the boundary harder.

The apiprobe split set the pattern (FEEDBACK §federation in
`docs/design/federation.md`). ASPIRE-06 extends it.

**Why it matters.** The 0.x window is the design window. Locking
the federated surface at v1.0 means doing the split BEFORE v1.0,
which means doing it in the late-0.x release sequence
(v0.28-v0.31). Doing it after v1.0 means breaking the v1.0
contract — much higher cost.

**Concrete evidence.** PAPER-26 + PAPER-27 + PAPER-28 (shipped in
v0.26 + v0.27) are the three most recent additions; they're all
under `mgf.common.fastapi.*`; they're 4 modules and ~1500 LOC of
test + impl combined. Every one of those is FastAPI-coupled,
imports nothing from a hypothetical `mgf-django` sibling, and is
the natural seed of `mgf-fastapi`.

**Suggested shape.** Per-minor extractions, one sibling per
release, in order of risk:

| Release | Extracts | Why this order |
|---|---|---|
| v0.28 | `mgf-fastapi` (incl. webhooks/security/testing + framework-domain concrete exceptions like `HmacVerificationError`) | Most-recently-shipped surface; freshest; touches PlasmaMapper most |
| v0.29 | `mgf-http` (`AsyncHttpClient`, `RetryPolicy`) | Single-purpose; clean boundary |
| v0.30 | `mgf-sqlalchemy` + `mgf-alembic` | Pair — alembic depends on sqlalchemy |
| v0.31 | `mgf-django` | Touches inthewords; isolated scope |
| v0.32+ | Stabilization, deprecation cleanup | Final polish before v1.0 |
| v1.0 | Lock the federated surface | Promote `experimental` → `stable` |

**Concrete decisions locked at decision time:**

1. **mgf-common stays the federation leaf.** Every sibling depends
   ON it; nothing depends BACK on a sibling. Per
   `docs/design/federation.md`.
2. **Hierarchy roots + status leaves stay in mgf-common.exceptions.**
   `AppError`, `HttpError`, `HttpClientError`, `HttpServerError`,
   and the generic status-code leaves (`HttpUnauthorized`,
   `HttpForbidden`, `HttpNotFound`, etc.) stay in mgf-common.
3. **Sibling-domain concrete leaves move with their sibling.**
   `HmacVerificationError` (currently in mgf-common.exceptions)
   moves to mgf-fastapi at v0.28. Same pattern for any other
   framework-domain concretes added later.
4. **Standards docs (`docs/standards/`) stay in mgf-common.** Source
   of truth for the federation; cross-cuts every sibling.
5. **Recipes split with their package.** Framework-specific
   recipes (`fastapi.md`, `webhooks.md`, `django.md`, `http.md`,
   `db.md`, `alembic.md`) move to their respective siblings.
   Cross-framework recipes (`framework-owned-logging.md`,
   `cloud-native-logging.md`) stay in mgf-common's `docs/recipes/`.
6. **`mgf-common init-project` stays in mgf-common.** Templates
   live in `mgf-common/templates/<kind>/` and pull in sibling
   deps via `pyproject` snippets. Init-project is the
   federation-orchestrator role.
7. **Each sibling has its own Codeberg repo, PyPI package,
   versioning, CHANGELOG, FEEDBACK, cutover guides, CI.**
   Independent semver per sibling.
8. **Per-minor staging.** Big-bang reorg rejected — incremental
   extraction validates the sibling-creation flow per piece and
   reduces cross-package mistake risk.

**Cost if deferred past v1.0.** Very high. v1.0 locks the public
surface; reorganizing afterwards either breaks the v1.0 contract
or requires v2.0 within 6-12 months.

**Cross-references.**
- `docs/design/federation.md` — the federation pattern (Python
  siblings).
- `docs/inprogress/federation_roadmap.md` — the **load-bearing
  tracking document** for this work (per-sibling specs, quality
  gates, decisions log, open questions, future improvements).
- ASPIRE-05 — long-horizon cross-language extension.
- PAPER-30 — first concrete non-Python sibling (`mgf-website-template`).

**Decision rationale.** Approved 2026-05-08. The maintainer
explicitly authorized "don't worry about current consumers below
v1.0" — consumers are co-designers; v0.x has the freedom v1.0
won't. Per-minor staging chosen over big-bang for validation.
Tight-coupling-runs-adapter→core (mgf-common changes drag every
sibling) accepted as the cost of clean separation.

**Retrospective.** *(Filled when v1.0 ships and the federated
shape locks.)*

---

#### <a id="PAPER-30"></a>`PAPER-30` — `mgf-website-template` cookiecutter sibling repo for Magogi marketing sites

| Field | Value |
|---|---|
| Status | ✅ SHIPPED — template + first consumer site both on Codeberg (2026-05-10 / 2026-05-11); deploy-verification step is consumer-side ops work, not a paper-closure blocker |
| Severity | 🟢 Nice-to-have |
| Submitter | Maintainer (Magogi-foundation-site design conversation, 2026-05-08) |
| Submitted | 2026-05-08 |
| Decision | Adopted |
| Decided on | 2026-05-10 |
| Scheduled for | sibling repo bootstrap (not a mgf-common release) |
| Shipped in | **Template:** `codeberg.org/magogi-admin/mgf-website-template` (root commit 2026-05-10). **First consumer:** `codeberg.org/magogi-admin/magogi-foundation` (root commit `14e17d8`, 2026-05-11) — the Magogi-foundation marketing site, generated from the template, smoke-tested (npm install + type-check 0/0/0 + build 4 pages + sitemap). |

**Concern.** Magogi has a recurring class of "marketing /
showcase web site" projects (foundation site, future product
landing pages, sub-org sites). Each is:

- Pure content delivery (vision, goals, products)
- Visual-heavy (images, GIFs, videos)
- One small interactive piece — contact form
- No API, no data store, no auth
- SEO + performance matter
- Often EN-only initially; some may go i18n later

Python is the wrong tool for this class. Static-site generators
(Astro / Hugo / Eleventy) win on every meaningful axis (cost,
edge-CDN latency, image pipeline, deploys, spam handling).

**Why it matters.** Without a template, every new Magogi
marketing site reinvents the structure (CI / deploy / form
handler / image pipeline / CLAUDE.md content). With a template,
they all start identical and converge organically.

**Concrete evidence.** The Magogi foundation site (first
instance) is being built now. With Infomaniak as the committed
host (Web Hosting product supports static deploy via SSH/SFTP/Git)
and EN-only locked, the stack is: Astro + Cloudflare-or-similar
for forms (or Infomaniak's own form-to-email if they grow one)
+ Infomaniak Web Hosting + GitHub/GitLab CI deploy.

**Suggested shape.** A separate Codeberg repo
`codeberg.org/magogi-admin/mgf-website-template`:

- **NOT** a `mgf-common` `--kind` (mgf-common stays Python-only).
- **NOT** a Python package (consumer doesn't `pip install` it).
- A cookiecutter-style template repo: clone, run `cookiecutter`
  or just `git clone + edit`, get an Astro project ready to
  deploy.

Contents:

```
mgf-website-template/
├── README.md                 # how to clone + customize
├── cookiecutter.json         # variables (project_name, primary_color, etc.)
├── {{cookiecutter.project_slug}}/
│   ├── astro.config.ts       # Astro config
│   ├── package.json
│   ├── src/
│   │   ├── components/       # hero, products-grid, contact-form, etc.
│   │   ├── layouts/
│   │   └── pages/
│   ├── public/               # images, robots.txt, sitemap.xml
│   ├── docs/CLAUDE.md        # references mgf-common standards by URL
│   ├── .github/workflows/    # build + deploy to Infomaniak via SFTP
│   └── README.md
└── LICENSE
```

The `CLAUDE.md` in the generated project references the
language-agnostic subset of `docs/standards/` in mgf-common (per
ASPIRE-05 — it's the first test of cross-language standards
travel).

**Cost if deferred past v1.0.** Negligible. mgf-website-template
is independent of mgf-common's versioning. It can land any time.

**Cross-references.**
- ASPIRE-05 — cross-language standards source-of-truth (long-
  horizon; mgf-website-template is the first non-Python project
  exercising the question).
- ASPIRE-06 — federated package architecture (Python siblings;
  mgf-website-template sits parallel as a non-Python template).
- `docs/design/federation.md` — the federation pattern.

**Decision rationale.** Adopted as proposed on 2026-05-10. The
template ships as a separate Codeberg repo, not absorbed into
`mgf-common`. Reasons:

- Mirrors the **`mgf-apiprobe` sibling pattern** — a domain-specific
  helper that earns its own repo because it cuts across consumers.
- The Magogi-foundation marketing site is concrete (in flight); the
  template formalises the shape so future Magogi marketing sites
  inherit it rather than reinvent it.
- It exercises **ASPIRE-05** in production: the generated project's
  `docs/CLAUDE.md` references the language-agnostic subset of
  `docs/standards/` by URL, so the standards travel into a non-Python
  project without `mgf-common` ever being installed there.
- Independent versioning: the template iterates on its own cadence;
  template improvements don't drag a `mgf-common` release.

**Forms strategy refinement (2026-05-10).** During the implementation
session, the open question "Cloudflare Workers vs Infomaniak's own
form-to-email" was resolved in favour of **Cloudflare Workers +
Turnstile + Resend**:

- Host-agnostic — survives moving off Infomaniak; the Worker pattern
  works regardless of where `dist/` is served.
- One Worker pattern serves N Magogi sites with per-site `[vars]`
  config (federation-aligned: share infrastructure across siblings).
- Turnstile is currently best-in-class invisible CAPTCHA; significantly
  better spam handling than Infomaniak's bare form-to-email would
  provide.
- Free tier (100k req/day Workers, generous Turnstile, free Resend
  tier) covers any realistic marketing-site volume.
- Cost: one extra account (Cloudflare) + one email-sending API key —
  small one-time setup that pays back across every future site.

**Retrospective (partial — 2026-05-10).** Local scaffold complete:

- Repo at `~/PycharmProjects/mgf_website_template/` (Codeberg slug:
  `mgf-website-template` per the hyphen convention for new federation
  siblings).
- Cookiecutter variables: `project_name`, `project_slug`,
  `project_description`, `domain`, `primary_color`, `contact_email`,
  `codeberg_namespace`, `infomaniak_remote_path`,
  `turnstile_site_key`. `project_slug` auto-derives from
  `project_name`; `contact_email` auto-derives from `domain`.
- Generated project shape: Astro 5 + TypeScript site (`src/` → `dist/`,
  4 pages: index/about/products/contact) + Cloudflare Worker
  (`worker/`, Turnstile + Resend) + GitHub Actions workflow that
  parallel-deploys static site (Infomaniak SFTP) + Worker (Cloudflare
  via official `wrangler-action`).
- Cross-language standards reference at `{{slug}}/docs/CLAUDE.md` —
  cites the language-agnostic mgf-common standards subset
  (`DESIGN_PRINCIPLES`, `SECURITY_STANDARD`, `SCOPE`, process-only
  bits of `RELEASING`, the `web/` subtree); explicitly skips Python-
  shaped standards (`ERROR_HANDLING`, `LOGGING`, `CONFIGURATION`,
  `TESTING`, `COMMON_COMPONENTS`).
- **Smoke test passed** (2026-05-10): `cookiecutter --no-input` →
  `npm install` → `npm run check` (0 errors) → `npm run build` (4
  pages + sitemap to `dist/`) → worker `tsc --noEmit` (clean). All on
  default cookiecutter inputs (`Magogi Foundation` / `magogi-foundation`).

**Implementation notes worth keeping.**

1. **Astro / Jinja2 brace conflict.** Astro's
   `prop={{ key: "value" }}` (object-literal-in-attribute) collides
   with Jinja2's `{{ var }}` syntax during cookiecutter rendering and
   throws `expected token 'end of print statement', got ':'`. Fix:
   bind the object to a `const` in the component frontmatter and
   reference it as a single-brace expression in the markup
   (`prop={obj}`). Caught at the smoke-test step on `index.astro:15`.
2. **GitHub Actions `${{ secrets.X }}` vs Jinja2.** Cookiecutter's
   Jinja2 evaluator parses `{{ ... }}` regardless of the leading `$`,
   so `${{ secrets.INFOMANIAK_SSH_KEY }}` raises an undefined-variable
   error. Fix: list `.github/workflows/deploy.yml` in the
   `_copy_without_render` array in `cookiecutter.json` and source all
   per-project values from CI secrets (so the workflow needs no
   substitution). Cleaner than scattering `{% raw %}` blocks.
3. **Worker secret hygiene.** `TURNSTILE_SECRET_KEY` and
   `RESEND_API_KEY` are pushed via the official `cloudflare/wrangler-action@v3`'s
   `secrets:` input on each deploy; the public Turnstile *site* key
   is intentionally in `wrangler.toml [vars]`. The CLAUDE.md spells
   out the closed-box rule.

**Close-out (2026-05-11).**

- [x] Push `mgf_website_template/` to `codeberg.org/magogi-admin/mgf-website-template`. Root commit landed 2026-05-10 (`8c9b456`).
- [x] Generate the Magogi-foundation site from the template; commit `14e17d8` on `codeberg.org/magogi-admin/magogi-foundation` (2026-05-11). Smoke test passes: `npm install` (361 packages) → `npm run check` (13 files / 0 errors / 0 warnings / 0 hints) → `npm run build` (4 pages + sitemap-index.xml).
- [x] No friction surfaced during generation. The template's cookiecutter inputs handle Magogi defaults cleanly (project_name → slug → contact_email → infomaniak_remote_path all derive sensibly from `domain="magogi.org"`).

**Out of scope for the paper close-out** (consumer-side ops work, not template/library work — friction in any of these would file as a new paper, not reopen this one):

- Wiring the eight CI secrets in the foundation-site Codeberg repo (`INFOMANIAK_SSH_KEY/HOST/USER/PATH`, `CLOUDFLARE_API_TOKEN/ACCOUNT_ID`, `TURNSTILE_SECRET_KEY/SITE_KEY`, `RESEND_API_KEY`, `CONTACT_FORM_ENDPOINT`).
- DNS A-record for `magogi.org` → Infomaniak Web Hosting target.
- Replacing placeholder body text + sample products with real Magogi content.
- First deploy (CI workflow runs on push to `main` once secrets are in place); end-to-end round-trip verification of the contact form (Turnstile + Resend).

**Retrospective (closing).** The template held up under its first real use. The cookiecutter input shape produced reasonable defaults from a single domain choice (`magogi.org`); no friction surfaced that warranted a template patch or a follow-up paper. The mgf-website-template repo gets to stay at v0.1 / unreleased — no tag, no PyPI (non-Python by design). Future Magogi marketing sites (product landing pages, sub-org sites) generate from the same template; if any of them surface friction, they file new papers.

This was also the first end-to-end exercise of ASPIRE-05 (cross-language standards travel via URL reference): the generated `docs/CLAUDE.md` cites mgf-common's standards by https URL without mgf-common ever being installed. That path now has a concrete consumer.

---

#### <a id="PAPER-62"></a>`PAPER-62` — `MARKER_TIER` is missing tier assignments for `integration` and `slow`; tests using either marker silently fall through to `DEFAULT_TIER`

| Field | Value |
|---|---|
| Status | ✅ SHIPPED |
| Severity | 🟢 Nice-to-have |
| Submitter | mgf-hrb migration agent (relayed via maintainer, 2026-05-26) |
| Submitted | 2026-05-26 |
| Decision | accept (`integration` → 2, `slow` → 3); cohort-pair with mgf-test-supervisor 0.1.4 (2026-05-27) |
| Decided on | 2026-05-27 |
| Scheduled for | mgf-common 0.41.6 + mgf-test-supervisor 0.1.4 (cohort pair) |
| Shipped in | mgf-common 0.41.6 + mgf-test-supervisor 0.1.4 |

**Retrospective.** Shipped 2026-05-27 as a two-package cohort wave (the alignment pin couples the packages structurally per TS-23). Added `integration` → tier-2 (cross-module / may launch subprocesses; fits the 2s per-test budget) and `slow` → tier-3 (long-signal tier alongside `perf` / `stress` / `e2e`) to BOTH `mgf.common.testing.MARKER_TIER` and the alignment-pinned mirror at `mgf.test_supervisor.tiers.MARKER_TIER`. The `TestMarkerTierAligned::test_marker_tier_mappings_match` pin stays green on both sides.

The parametrized canonical-pin test (`TestCanonicalMarkerTier::test_canonical_mapping_pinned`) was extended from 11 rows to 13. No behaviour change for tests that don't use either marker; tests that DO use them are now classified into their correct tier rather than silently falling into tier-1 — more honest budget accounting + more accurate auto-tier-bump decisions.

mgf-common's dev-extras pin on `mgf-test-supervisor>=0.1.2,<0.2` stays in-window (0.1.4 is inside the bound), so no consumer pin walk needed. mgf-test-supervisor 0.1.4 published to PyPI alongside mgf-common 0.41.6 — REL-11 Phase B sequential ship.

**Concern.** The federation 13-marker taxonomy is declared in every sibling's `[tool.pytest.ini_options].markers` block, but `mgf.common.testing.MARKER_TIER` (and its alignment-pinned mirror `mgf.test_supervisor.tiers.MARKER_TIER`) only contain **11** of those 13: `integration` and `slow` are absent. Tests decorated with either marker fall through to `DEFAULT_TIER = 1` via `effective_tier(...)`, getting a tier-1 wall-clock budget regardless of what they actually do.

**Why it matters.**

- `integration` tests' own docstring describes them as "cross-module integration test (slower; may launch real subprocesses)." A test that genuinely launches subprocesses + waits for them is unlikely to fit tier-1's `<0.30s per-test / <30s suite` budget. The supervisor silently mis-classifies these into tier-1, then either reports them as duration-exceeded (auto-tier bump after rolling-median ≥ 5× the budget) or simply burns more of the tier-1 suite budget than is fair to actual unit tests.
- `slow` is explicitly marked deprecated in every sibling's marker block: *"legacy marker for tests >1s wall-clock (TS-18). Deprecated. Removed in v2.0."* Falling through to `DEFAULT_TIER` is arguably the right behaviour here — but it's the right behaviour by accident, not by design. Future readers of `_markers.py` see the gap with no comment explaining it.

**Concrete evidence.** mgf-hrb's pre-migration `scripts/_mtest/tiers.py` carried the same gap (the supervisor's originating code never assigned tiers to these two markers). When the supervisor extracted to `mgf-test-supervisor` v0.1.0, the gap propagated unchanged. mgf-hrb's migration agent caught it on the consumer side: *"the published map is 11 markers (missing `integration`, `slow`). mgf-hrb has no tests using those today, so the tier-1 fallback is harmless — but file an upstream PR if a future test does."*

The alignment contract test (`tests/unit/testing/test_supervisor_alignment.py::TestMarkerTierAligned`) currently passes — both copies have the same 11 markers, so they're aligned. The test catches drift between the two surfaces, but cannot catch "both sides are missing the same two."

**Suggested shape.**

1. **Add `integration: 2` to the canonical `MARKER_TIER`** in `src/mgf/common/testing/_markers.py`. Tier-2 ("Promise pins" — `2s per-test / 120s suite`) is the right home: integration tests assert design-promise-shaped properties + may launch subprocesses; they need a budget that accommodates subprocess startup costs.
2. **Leave `slow` out** with an explicit comment in `_markers.py` recording why: it's deprecated (TS-18 / v2.0 removal), `DEFAULT_TIER` fallthrough is intentional, and adding it to the table would constitute encouragement.
3. **Propagate** the addition to `mgf-test-supervisor/src/mgf/test_supervisor/tiers.py` in the same commit (the alignment test enforces lockstep).
4. **Add a unit test** asserting `effective_tier(["integration"]) == 2` so the answer doesn't regress.

**Decision rationale.** This is a v1.0-pre design call: pre-v1.0 is the cheap window to add this entry, and the alignment contract test will keep both copies honest going forward. Per AP-04 (post-v1.0 changes cost 2x), missing it now means adding it later via a deprecation cycle on the tier-1-budgeted integration tests' consumers.

---

#### <a id="PAPER-63"></a>`PAPER-63` — Woodpecker artifact upload for `.gates/` (test-supervisor diagnostic bundles) is TBD because no artifact plugin is federation-standardised

| Field | Value |
|---|---|
| Status | 🆕 OPEN |
| Severity | 🟢 Nice-to-have |
| Submitter | mgf-hrb migration agent (relayed via maintainer, 2026-05-26) |
| Submitted | 2026-05-26 |
| Decision | (pending) |

**Concern.** The federation test supervisor writes its per-failure diagnostic bundles + `run.jsonl` + `summary.md` + `junit-NNNN.xml` to `.gates/last-run/` (TS-26). On CI, these are the gold for diagnosing flakes / hangs / crashes — but **only if they're uploaded as build artifacts.** Today no sibling's `.woodpecker.yml` actually uploads `.gates/`, because Woodpecker has multiple artifact-upload plugins (`woodpecker-plugins/s3`, `appleboy/drone-scp`, `plugins/github-release`, ad-hoc curl scripts to a self-hosted S3) and the federation hasn't picked one.

**Why it matters.** When a test fails in CI today, the operator gets pytest's truncated output in the Woodpecker log and nothing else. The supervisor's failure-bundle (the whole point of TS-26 — per-failure `.txt` with traceback + env fingerprint + daemon state + dmesg + thread-stacks) is written to `.gates/` and discarded when the Woodpecker container exits. The on-call playbook (`docs/testing/DIAGNOSING_HANGS.md`) tells the operator to read those bundles, but a CI failure can't produce them at all in the current setup. This is exactly the diagnostic surface the TS-22..TS-27 sweep was supposed to deliver.

**Concrete evidence.** mgf-hrb's `.woodpecker.yml` (commit `104ce1b`, the first to land Woodpecker on Codeberg per the federation choice) contains a `TBD` block where the `.gates/` upload step would go. mgf-common's own `.woodpecker.yml` has no upload step at all. The remaining 6 sibling adopters (mgf-http, mgf-fastapi, mgf-django, mgf-sqlalchemy, mgf-alembic, mgf-apiprobe) don't ship a `.woodpecker.yml` yet — they'll inherit whatever pattern the federation picks here.

**Suggested shape.**

1. **Pick one plugin.** Reasonable candidates, in rough order of federation-ergonomic fit:
   - `appleboy/drone-scp` (Drone-CI plugin; works on Woodpecker via the Drone-compat shim; ships artifacts to any SSH-reachable host — and Codeberg's Pages, an Infomaniak SSH target, or a self-hosted server all qualify).
   - `woodpecker-plugins/s3` (if the federation decides to centralise on S3-compatible storage — but adds an external dep + storage cost).
   - Ad-hoc bash + `curl` to a Codeberg release-asset upload (cheapest; no extra plugin; ties artifacts to git tags rather than every CI run).
2. **Codify** the choice in `docs/standards/WORKFLOWS.md` — extend WF-* to specify the artifact-upload step shape (when triggered: failure-only? nightly? always?; what's uploaded: `.gates/last-run/` only? plus `junit-NNNN.xml`?; retention policy).
3. **Provide a copy-pasteable Woodpecker step fragment** in `mgf-common/docs/recipes/test-supervisor-adoption.md` (or its mgf-test-supervisor successor) so adopters get the pattern verbatim.
4. **Apply to the 7 existing `.woodpecker.yml` files** as a federation-wide cleanup commit.

**Decision rationale.** This is a federation infrastructure decision the maintainer makes once; consumers + agents implement consistently afterward. Picking before the v1.0 cut means TS-26's full diagnostic surface ships intact on first release; picking after means a v1.0.x patch wave.

**Cross-references.**

- TS-26 (`mgf-common/docs/standards/TESTING.md` §19): the supervisor's failure-bundle contract.
- `mgf-common/docs/testing/DIAGNOSING_HANGS.md`: the on-call playbook that depends on `.gates/` being readable.
- WF-01..WF-12 (`mgf-common/docs/standards/WORKFLOWS.md`): the Woodpecker CI rules. WF-09 (pin every external dep) applies to whichever artifact plugin is picked.

---

#### <a id="PAPER-64"></a>`PAPER-64` — `mgf-common agents` briefing cites `WF-01..WF-11` but the standard ships `WF-01..WF-12`; same drift appears in PAPER-63's cross-references

| Field | Value |
|---|---|
| Status | ✅ SHIPPED |
| Severity | 🟢 Nice-to-have (docs drift, mechanical fix) |
| Submitter | mgf-hrb sibling-#8 follow-up (mgf-hrb commit `104ce1b`) |
| Submitted | 2026-05-26 |
| Decision | accept (option 1 — mechanical replace, 2026-05-26) |
| Decided on | 2026-05-26 |
| Shipped in | mgf-common (this commit) |

**Concern.** The agent briefing printed by `mgf-common agents` says:

> **CI/CD:** Woodpecker CI on Codeberg (rules WF-01..WF-11 in `mgf-common standards workflows`)

But `mgf-common/docs/standards/WORKFLOWS.md` carries a rule table running `WF-01..WF-12` (WF-12 added: "Every CI step MUST surface its exit code, the exact command that ran, and enough log context"). The same `WF-01..WF-11` drift appears in PAPER-63's cross-reference list (line above this entry). Tracking the same off-by-one in two places suggests the rule count is hand-maintained rather than derived.

**Why it matters.** Agents adopting the federation (mgf-hrb did this today) read the briefing as the authoritative entry-point. A rule-count off-by-one is small but corrosive: it teaches agents to second-guess everything the briefing claims. The fix is mechanical but the discipline is "the briefing's claims about the standards should be derived from the standards, not duplicated."

**Suggested shape.** Two options, smallest first:

1. **Quickest fix** — grep-replace `WF-01..WF-11` → `WF-01..WF-12` in the agents-briefing template + PAPER-63's cross-references. Five minutes; closes today's drift.

2. **Durable fix** — render the rule count by reading the WORKFLOWS.md rule table at briefing-generation time. The briefing already imports from `mgf.common` to run; reaching into the standards markdown to count `WF-NN` rule IDs and substitute the range into the template would prevent recurrence whenever WF-13 or TS-28 lands. Same shape would help the TS-22..TS-27 mention if a future TS-28 ships.

A search across the repo for hand-maintained "X-NN..X-MM" ranges would find every other instance worth fixing in the same pass; my quick grep found `WF-01..WF-11` (briefing + PAPER-63), `WF-01..WF-12` (correct; WORKFLOWS.md self-references), `TS-22..TS-27` (correct as of today), `WF-01..WF-12` (catch-up's own output). Mostly consistent; the briefing is the visible outlier.

**Cost if deferred past v1.0.** Low — fix is additive and reversible at any time. A v1.0 briefing with a wrong rule count is embarrassing but not breaking.

**Decision rationale.** Accept option 1 (mechanical replace). Option 2 (derive rule count from WORKFLOWS.md at briefing-render time) is the durable shape but changes the briefing's contract (currently a `Final` string constant pinned to STARTHERE.md by test). A separate paper proposing the derive-from-source rendering for ALL `X-NN..X-MM` ranges (WF, TS, AP, etc.) is the right v1.x follow-up; doing it as a one-off for WF would set an inconsistent precedent.

**Retrospective.** Shipped 2026-05-26. Three sites in source carried `WF-01..WF-11`:
1. `src/mgf/common/cli/_agents.py` (the `AGENTS_BRIEFING` constant, item 5).
2. `STARTHERE.md` (the human-readable mirror; pinned to the constant by `tests/unit/cli/test_agents.py::TestStartHereMatch`).
3. `src/mgf/common/cli/_catch_up.py` (4 references — docstring of `_check_woodpecker_ci` + 3 in the warn-message text; added in the same session that introduced the off-by-one in the briefing).
Plus one site in `FEEDBACK.md` (PAPER-63's cross-reference list). All four corrected; the 4 quoted occurrences in PAPER-64's own concern + suggested-shape paragraphs are left as-is because they're literal quotations of the bug. Pin test (4 assertions) still green; catch-up tests (132 assertions) still green.

---

#### <a id="PAPER-65"></a>`PAPER-65` — `mgf-common catch-up` reports the mgf-common pin is STALE but defers the bump to a manual `pip index versions` lookup; could close the loop with a PyPI query + opt-in rewrite

| Field | Value |
|---|---|
| Status | ✅ SHIPPED (read-only path); apply-path deferred to follow-up |
| Severity | 🟢 Nice-to-have |
| Submitter | mgf-hrb sibling-#8 catch-up follow-up (mgf-hrb commit `104ce1b`+1) |
| Submitted | 2026-05-26 |
| Decision | accept (read-only `--check-pypi` first; `--apply --check-pypi` deferred — 2026-05-26) |
| Decided on | 2026-05-26 |
| Shipped in | mgf-common (this commit) |

**Concern.** On `mgf-common catch-up`, when the project's `mgf-common` pin window doesn't include the current editable-installed version, the tool prints:

> ⚠️  pyproject.toml — mgf-common pin
>     STALE: declared `>=0.39.0,<0.40` but current (editable-install) mgf-common is `0.40.0`. NOT auto-rewriting — editable installs can be AHEAD of PyPI, and rewriting to `>=0.40.0,<0.41` would produce a pin no plain-PyPI resolver can satisfy. Verify the latest PyPI-published window with `pip index versions mgf-common` and bump the pin by hand to that window.

The discipline is correct (auto-rewrite to a window that PyPI can't satisfy would break every downstream `pip install`), but the remediation makes the agent do two manual steps: (1) `pip index versions mgf-common` (or query `https://pypi.org/pypi/mgf-common/json`), (2) hand-edit pyproject.toml. The information needed for the safe rewrite is one HTTPS call away.

**Why it matters.** Every project in the federation re-pins `mgf-common` whenever it bumps a minor. With 8 framework siblings + N consumer projects, the manual round-trip multiplies. More subtly: the manual `pip index versions` step doesn't compose with `--apply` — there's no path to "audit + apply everything mechanical including the pin bump" without leaving the tool. An agent that wants to fully automate catch-up has to either skip the pin (wrong) or shell out for the PyPI query (boilerplate).

**Suggested shape.** Two flag-gated extensions:

1. `--check-pypi` (read-only): when the pin is STALE, query PyPI's JSON API for the latest published version, AND, if it's inside the would-be window, append a line to the drift report: "PyPI has 0.40.0 published — safe to rewrite to `>=0.40.0,<0.41` (rerun with `--apply` + `--check-pypi`)." If PyPI doesn't have it yet, say so ("editable install at 0.40.0 but PyPI's latest is 0.39.0 — wait for PyPI publish before rewriting"). Either case removes the manual lookup; the agent gets a confident decision in one command.

2. `--apply --check-pypi` (write): apply the same rewrite catch-up's `--apply` would do for OTHER drift items, but ONLY when `--check-pypi` confirms PyPI is at the target version. Defaults remain conservative; agents opt in to the PyPI round-trip explicitly.

PyPI's JSON API is well-cached, the call is sub-100ms, no auth needed. The `mgf.common.process` infrastructure already wraps subprocess timeouts the same way it would wrap an HTTP call here (or a stdlib `urllib.request.urlopen` is enough — no new dep).

**Cost if deferred past v1.0.** Low — additive flag, can land any time. The cost compounds with sibling count: an N-sibling federation does N pin bumps per mgf-common minor, all currently manual.

**Decision rationale.** Accept the read-only path now; defer the write path (`--apply --check-pypi` triggering the actual rewrite) to a follow-up paper. The read-only path delivers most of the value (closes the manual `pip index versions` step) with zero risk; the write path is more sensitive because it crosses a "agent autonomously edits pyproject.toml on PyPI-confirmation" line that warrants explicit consumer opt-in via the existing `--apply` flag — and the apply path is enabled by setting `fix=` on the finding, which the existing `--apply` infrastructure picks up naturally. So the deferral is incremental confidence-building, not a missing feature.

**Retrospective.** Shipped 2026-05-26 (this commit). New flag `--check-pypi` in `add_catch_up_args` enables a read-only PyPI JSON-API query for the latest published mgf-common version. New helper `_query_pypi_latest_version(package, *, timeout_s=5.0)` uses stdlib `urllib.request` (no new dep) with a 5-second timeout; returns `str | None` (None = network/parse failure, caller falls back gracefully).

The editable-install branch of `_check_pyproject_pin` was extended with three new outcomes when `--check-pypi` is set:
- **PyPI caught up:** message says "safe to rewrite" + finding carries `fix=` callable, so `--apply --check-pypi` performs the rewrite using the existing apply-loop infrastructure.
- **PyPI lags:** message says "wait for PyPI to catch up" (references Q-4 federation publish backlog); no fix attached.
- **PyPI query failed:** falls back to the manual-remediation message gracefully.

Default behaviour (no flag) preserved: same message as v0.17.1, plus a tip mentioning `--check-pypi` is available. 9 unit tests added (`TestQueryPyPILatestVersion` × 4 + `TestCheckPyPIFlag` × 4 + `TestCheckPyPIArgWiring` × 1) covering: success / HTTP error / malformed JSON / missing-version-field / default-flag-off behaviour / pypi-caught-up / pypi-lags / query-failed-fallback / arg parsing.

Deferred to follow-up paper: even-cleaner UX would be `--check-pypi` auto-enabling itself when the editable-install case fires (no need for an explicit flag). Punting because the explicit-opt-in keeps the network call gated to consumer intent; auto-enabling would surprise consumers running catch-up in air-gapped envs. Open for federation discussion.

---

#### <a id="PAPER-77"></a>`PAPER-77` — TS-23 wiring is silently broken without `pytest-timeout`; supervisor's `CRASHED` outcome hides the cause

| Field | Value |
|---|---|
| Status | ✅ SHIPPED (parts 2 + 3); part 1 lives in mgf-test-supervisor |
| Severity | 🟡 High |
| Submitter | hrb-app (commit `d45279f`) |
| Submitted | 2026-05-26 |
| Decision | accept — all three options applied across both repos (2026-05-26) |
| Decided on | 2026-05-26 |
| Shipped in | mgf-common (part 2; this commit) + mgf-test-supervisor v0.1.2 (parts 1 + 3 — follow-up commit) |

**Concern.** `mgf-common catch-up` reports the federation test-supervisor finding (TS-22..TS-27) with this remediation prose:

> Missing TS-23 timeout config. Add to `[tool.pytest.ini_options]`:
> ```
> timeout = 30
> timeout_method = "thread"
> faulthandler_timeout = 60
> ```
> (Per-test budget + thread-stack dump on hang. The supervisor relies on this when invoking pytest as a subprocess.)

Those three keys are **`pytest-timeout` options**, but the prose doesn't say so and `mgf-test-supervisor` doesn't pull `pytest-timeout` as a transitive dep. A consumer who follows the instructions literally adds the keys and gets:

```
INTERNALERROR> pytest.PytestConfigWarning: Unknown config option: timeout
```

at pytest collection time (because TS-04's `filterwarnings = ["error"]` promotes the warning). Worse, `mgf-test-supervisor --self-check` then reports every canned test as `CRASHED` with **no hint** that the underlying pytest stderr says "Unknown config option: timeout" — the supervisor's per-chunk summary just reads `0 passed, 1 CRASHED (1 total)` four times in a row and exits FAIL.

**Why it matters.** This is a first-contact failure mode: the consumer follows catch-up's printed instructions exactly, runs the self-check the standards recommend as the success signal, and gets an opaque failure with no obvious cause. The path back to the root cause (`pytest -q` shows `INTERNALERROR`; deduce that's `pytest-timeout`-related) requires the consumer to bypass the supervisor entirely. We hit this verbatim wiring sibling-#9 (hrb-app) into TS-22..27 — three minutes of "what's broken" debugging that should have been zero.

Concretely, this fails the federation's own "one-shot adoption" promise: the supervisor README says wiring is a 5-minute task; in practice, anyone who isn't already running `pytest-timeout` gets opaque CRASHEDs until they figure out the missing plugin.

**Suggested shape.** Three options, in increasing order of robustness:

1. **Declare `pytest-timeout` as a dependency of `mgf-test-supervisor`.** Smallest fix. The supervisor's wire-protocol promises rely on the timeout keys being honored; the dep that honors them belongs in its dependency closure. Whether it's a runtime dep or a pytest-plugin-loader extra is orthogonal.

2. **`catch-up` adds `pytest-timeout` to the printed dev-dep block.** Cosmetic-but-correct. The current TS-22..27 finding lists only `mgf-test-supervisor` as the dep to add; add `pytest-timeout>=2.3` alongside it. If option (1) lands, this becomes redundant; if not, it's the minimum honest fix.

3. **`mgf-test-supervisor --self-check` surfaces underlying pytest stderr on `CRASHED` outcomes.** The real ergonomic fix. When a chunk crashes (non-zero exit + zero results), capture pytest's stderr and print the last 20 lines under each "FAIL" line, gated on `--verbose` or always (it's already an error path). This generalises beyond the missing-plugin case — any pytest INTERNALERROR or import-time failure today is invisible behind a single `CRASHED` count.

Options (1) and (3) compose: (1) prevents the immediate trip; (3) makes the *next* unknown trip self-explanatory.

**Cost if deferred past v1.0.** Medium — every new sibling that adopts TS-22..27 hits this once. The cost is per-adoption × N siblings × (3-5 minutes of head-scratching), plus the credibility hit of "the supervisor's adoption guide doesn't work on first try." Cheap to fix; expensive to leave.

**Decision rationale.** Accept all three options; they compose.
- Part (2) is the immediate honest fix: `catch-up` says "you need pytest-timeout" explicitly. Removes the friction for adopters who read the audit output.
- Part (1) belongs in `mgf-test-supervisor` — making `pytest-timeout` a transitive dep means the install command alone gets the consumer to a working state. PyPI-publish-deferred constraint means this is a Codeberg-only library today; declaring it in `[project.dependencies]` is the right shape.
- Part (3) is the durable fix: the supervisor's `--self-check` should surface the underlying pytest stderr on a CRASHED outcome. Generalizes beyond pytest-timeout — any first-contact failure stops being opaque.

Parts (1) and (3) live in `mgf-test-supervisor` and are paired in a follow-up commit there.

**Retrospective.** Part 2 shipped in mgf-common (this commit):
- `_check_test_supervisor_adoption` now audits `pytest-timeout` as a third signal alongside `mgf-test-supervisor` dep + `timeout_method` config. The warn-message lists both deps in the "Missing dev dep(s)" block, with an explicit "without `pytest-timeout`, the timeout keys below produce opaque CRASHEDs — PAPER-77" footnote.
- The OK-branch message now reads "`mgf-test-supervisor` + `pytest-timeout` declared in dev extras; `timeout_method = \"thread\"` set in pytest config."
- The fix-callable for the warn branch is `_apply_supervisor_wiring` (PAPER-73), which inserts BOTH deps so single-command resolution is now possible.
- Tests in `TestTestSupervisorAdoptionCheck` updated (the helper now takes `pytest_timeout_dep` as a third toggle; 8 tests cover the truth table).

Parts 1+3 shipped in mgf-test-supervisor (follow-up commit in that repo): see mgf-test-supervisor/FEEDBACK.md for the cross-repo retrospective.

---

#### <a id="PAPER-76"></a>`PAPER-76` — `catch-up` should detect missing `import-linter` Qt-isolation contracts for GUI-consumer projects

| Field | Value |
|---|---|
| Status | ⏸ DEFERRED |
| Severity | 💭 Aspirational |
| Submitter | hrb-app (commit `d45279f`) |
| Submitted | 2026-05-26 |
| Decision | defer to roadmap cadence per submitter's own 💭 classification (2026-05-26) |
| Decided on | 2026-05-26 |

**Concern.** `mgf-common catch-up` audits the four green gates, the federation contract, marker taxonomy, the test-supervisor wiring, and the Woodpecker config. It does **not** audit whether a GUI-shaped consumer has the GUI-01 / GUI-06 import-linter contracts wired (Qt isolated to the presentation layer; presentation forbidden from reaching past the controller layer to the SDK). The `GUI_CONSUMERS` standard prescribes those contracts in §1 with paste-able snippets, but enforcement is left to the consumer's hand-discipline.

**Why it matters.** GUI-01 is the load-bearing layering rule for any GUI consumer — once `PySide6` bleeds into `core`, it's effectively unremovable. The federation's discipline says "catch it in CI, not in code review months later." `catch-up` is the obvious enforcement seam for that — same way it already verifies the four gates exist and the federation markers are registered.

For hrb-app today: the contracts ARE present (we wrote them by hand following GUI_CONSUMERS §1), but the only thing keeping the next sibling honest is whether someone reads that standard top-to-bottom. A drift audit would close the loop.

**Suggested shape.** Extend `catch-up` with a kind-aware check:

```python
def _check_gui_isolation_contracts(target_dir: Path, *, kind: str) -> Finding:
    # Trigger only if project declares PySide6 / PyQt6 as a runtime dep.
    if not _depends_on_qt(target_dir):
        return Finding.skip("Not a GUI consumer.")

    contracts = _parse_importlinter_contracts(target_dir / "pyproject.toml")
    findings = []
    if not any(c.matches("forbidden", forbidden={"PySide6", "PyQt6"}) for c in contracts):
        findings.append("GUI-01: missing `forbidden` contract for PySide6/PyQt6 outside presentation layer")
    if not any(c.type == "layers" for c in contracts):
        findings.append("GUI-06: missing `layers` contract")
    ...
```

Triggers when:
- `pyproject.toml` declares `PySide6` or `PyQt6` in `[project.dependencies]`, AND
- one of the GUI-01 / GUI-06 contracts is missing.

The `--apply` path could **insert the canonical contracts** parametrised on the project's root package name (which catch-up already knows). The `GUI_CONSUMERS` doc would then say "if you skipped wiring this by hand, run `catch-up --apply`" instead of "follow this paste-able recipe."

Detection alone (without `--apply`) is the minimum value; insertion is the durable form.

**Cost if deferred past v1.0.** Low individually, compounds at federation scale. Each new GUI sibling either reads `GUI_CONSUMERS` end-to-end (rare in practice — agents grep for what they need) or hand-rolls the contracts wrong. We've already accepted that catch-up is the place where "you missed step N" lives; this is one more step.

**Decision rationale.** Defer to roadmap cadence — the submitter classified this 💭 aspirational. Per FEEDBACK.md §1.2: *"💭 Aspirational … Not actionable today. SLA: Kept for roadmap reviews; drives major-version planning."* The right scope for this is post-v1.0 federation-wide tooling expansion, alongside other consumer-kind-specific audits (FastAPI middleware check, Django app-config check, etc.). Shipping the GUI-specific check in isolation would set an asymmetric precedent.

**What would unblock this.** A second GUI consumer landing on the federation (vm-vmanager-app is the only one shipping today; hrb-app is sibling #2). Two concrete cases reveal the right shape for the audit; one is N=1 design territory. The "import-linter contracts are GUI-01/GUI-06" wording in the standard is also worth confirming from a second consumer's perspective — vm-vmanager-app's contracts may differ enough that hard-coding the audit against today's contracts would re-pin the audit shape too early.

**Retrospective.** *(Filled when shipped — currently DEFERRED.)*

---

#### <a id="PAPER-75"></a>`PAPER-75` — `GUI_CONSUMERS` standard cites `hrb-app` at the post-rename URL it never had (`hrb_app` underscore)

| Field | Value |
|---|---|
| Status | ✅ SHIPPED |
| Severity | 🟢 Nice-to-have |
| Submitter | hrb-app (commit `d45279f`) |
| Submitted | 2026-05-26 |
| Decision | accept — one-line fix + federation-wide sweep (2026-05-26) |
| Decided on | 2026-05-26 |
| Shipped in | mgf-common (this commit) |

**Concern.** `docs/standards/GUI_CONSUMERS.md` (printable via `mgf-common standards gui`) cites hrb-app as a reference consumer at:

```
[`hrb-app`](https://codeberg.org/magogi-admin/hrb_app)
```

— note the underscore in the URL. The actual Codeberg repo is `magogi-admin/hrb-app` (hyphen) per the NM-04 rename (local dev dir + remote both flipped to hyphen 2026-05-25 in hrb-app commit `50bd59d`). The current link 404s.

The display text inside the brackets is correct (`hrb-app`); only the URL is stale.

**Why it matters.** Cosmetic, but the standard's value depends on those two reference URLs being navigable — a federation agent following the standard will click through to read hrb-app's source as the "smaller, end-to-end-readable" reference and land on a 404. Trust signal for newer-still federation siblings reading the same doc as their template.

**Suggested shape.** One-character fix in `GUI_CONSUMERS.md`:

```diff
-[`hrb-app`](https://codeberg.org/magogi-admin/hrb_app)
+[`hrb-app`](https://codeberg.org/magogi-admin/hrb-app)
```

Worth grepping `docs/standards/*.md` and `src/mgf/common/cli/_agents.py` for any other lingering `hrb_app` URL references at the same time (none jumped out on my pass, but cheap to confirm).

**Cost if deferred past v1.0.** Negligible — but the standards docs are pre-1.0 living text, this is the low-friction window to fix surface drift.

**Decision rationale.** Accept. The agent suggested grepping federation-wide; that turned up 5 more underscore URLs (3 in `docs/cutover/v0.30.0.md`, 1 in `docs/standards/OPERABILITY.md`, plus the original 2 in `GUI_CONSUMERS.md`). All 6 fixed in the same pass. The 2 historical refs in `NAMING.md §4` (documenting the cutover from underscore → hyphen) are legitimate and kept verbatim.

**Retrospective.** Shipped 2026-05-26 (this commit). Six underscore→hyphen substitutions:
- `docs/standards/GUI_CONSUMERS.md` line 5: `hrb_app` → `hrb-app`.
- `docs/standards/GUI_CONSUMERS.md` line 615: same.
- `docs/standards/OPERABILITY.md` line 334: `mgf_common` → `mgf-common` (in the OP-03 URL-resolve audit row).
- `docs/cutover/v0.30.0.md` lines 7-9: `mgf_sqlalchemy` / `mgf_alembic` / `mgf_fastapi` → hyphenated (companion-guide URLs in the cutover doc).

No code changes; no tests. Federation-wide grep confirms no other live underscore Codeberg URLs remain (`NAMING.md §4` historical refs intentionally preserved).

---

#### <a id="PAPER-74"></a>`PAPER-74` — `mgf-common agents` briefing presents `docs/CLAUDE.md` as the "older layouts" path; the layout split confuses new readers

| Field | Value |
|---|---|
| Status | ✅ SHIPPED |
| Severity | 🟢 Nice-to-have |
| Submitter | hrb-app (commit `d45279f`) |
| Submitted | 2026-05-26 |
| Decision | accept — option (a): align `init-project` with the DOC-07 canonical path; defer cross-project migration (2026-05-26) |
| Decided on | 2026-05-26 |
| Shipped in | mgf-common (this commit) |

**Concern.** The canonical agent briefing (`mgf-common agents`, source: `_agents.py:174-178`) instructs:

> Read this project's `CLAUDE.md` (`docs/claude/CLAUDE.md`, or
> `docs/CLAUDE.md` on older layouts) and `AGENTS.md`. Missing?
> `mgf-common catch-up --apply` creates them.

The same "or `docs/CLAUDE.md` on older layouts" framing appears in `AGENTS.md` (the catch-up-managed file template at `_init_project.py:71-83`). The implication is that `docs/CLAUDE.md` is a *legacy* path that should eventually migrate to `docs/claude/CLAUDE.md`.

In practice, hrb-app (scaffolded by `mgf-common init-project` 2026-05-24, mgf-common pin `>=0.39,<0.40` then bumped to `>=0.40.0,<0.41` today) was generated with `docs/CLAUDE.md` directly. `catch-up` at 0.40.0 has no complaint about that path. So `docs/CLAUDE.md` is both the **scaffolded-today path** AND simultaneously framed as the **legacy path**. A reader inferring intent reads this as "you're on a deprecated layout, expect a migration prompt soon" — which isn't true.

**Why it matters.** Low-stakes individually; meaningful as a clarity signal. The briefing is the canonical entry point for every agent in every federation project; ambiguity there is amplified across N projects × M re-reads. And the very next bullet says `catch-up --apply creates them` — if a path is "older", agents will (correctly) assume `--apply` would upgrade to the new path. It doesn't.

Two reads of the situation, either is reasonable:
- (a) `docs/claude/CLAUDE.md` is the *target* layout and catch-up should eventually do the migration. Briefing is right; catch-up is incomplete.
- (b) Both layouts are first-class; the "older" framing is just inherited from when `docs/claude/` was new. Briefing should drop "older layouts" and present them as peers.

The published `mgf-hrb` sibling sits at `docs/claude/CLAUDE.md` while `hrb-app` (scaffolded later) sits at `docs/CLAUDE.md`, which suggests the convention has wobbled.

**Suggested shape.** Pick one and align:

- If **(a)**: extend `init-project` to scaffold at `docs/claude/CLAUDE.md` always; extend `catch-up --apply` to migrate `docs/CLAUDE.md` → `docs/claude/CLAUDE.md` (one git mv + path-aware rewrite of the auto-managed block). Keep the briefing's "older layouts" language.

- If **(b)**: drop "on older layouts" from the briefing and from the `AGENTS.md` template. Both paths are supported, neither is preferred; agents can find whichever exists by trying both.

A short note in `STARTHERE.md` or `FEDERATION.md` ratifying the decision closes the loop for newer readers.

**Cost if deferred past v1.0.** Low — but post-1.0 a layout-migration carries a deprecation cycle (AP-04 discipline). Cheaper to resolve while both layouts are still pre-stability.

**Decision rationale.** Take option (a) — `docs/claude/CLAUDE.md` IS the DOC-07 canonical (PAPER-54's "Approach A — canonical DOC-07 wins"; shipped earlier this v1.0-prep arc). The contradiction the agent surfaced was that `init-project` was scaffolding the legacy path while catch-up's `_check_claude_md` already had the canonical-wins-with-legacy-fallback policy. Aligning the scaffolder closes the contradiction; the briefing's "older layouts" wording becomes accurate for newly-scaffolded projects.

Deferred: the cross-project migration path (`catch-up --apply` git-mv'ing `docs/CLAUDE.md` → `docs/claude/CLAUDE.md` for existing consumers on the legacy path). Reasoning: structured-move + path-aware rewrite + handling the project-specific section below the auto-managed block is more invasive than this session's scope. Existing consumers on the legacy path stay valid (catch-up accepts both per PAPER-54); migration becomes a future paper if/when federation-wide alignment becomes a hard requirement.

**Retrospective.** Shipped 2026-05-26 (this commit). Three edits to `src/mgf/common/cli/_init_project.py`:

1. **`claude_md_path` line 151**: `target_dir / "docs" / "CLAUDE.md"` → `target_dir / "docs" / "claude" / "CLAUDE.md"`. The `Path.mkdir(parents=True, exist_ok=True)` call already handles the new parent directory creation.
2. **Module docstring** (line 8): updated the file-list to name the canonical path with the rationale.
3. **Usage example** in the docstring + the "Next:" message at end of `run_init_project`: both now reference `docs/claude/CLAUDE.md`.

Tests updated in `tests/unit/cli/test_init_project.py`:
- `test_dry_run_prints_what_would_create`: asserts both the canonical AND legacy paths are absent on disk after dry-run.
- `test_writes_scaffold_files`: assert canonical exists, legacy explicitly does NOT.
- `test_force_overwrites`: read content from canonical path.

All 29 init-project tests pass. Full pytest: 1788 passed (no net change — 3 test-line edits, no count change).

The "older layouts" wording in the briefing now reads accurately for new projects scaffolded by current init-project. Existing consumers (hrb-app, vm-vmanager-app, etc.) on the legacy path are unchanged — catch-up still accepts `docs/CLAUDE.md` per PAPER-54, just without auto-migration to the canonical.

---

#### <a id="PAPER-73"></a>`PAPER-73` — TS-22..27 adoption isn't actually `--apply`-able; remediation prose is hand-edit instructions in `catch-up`'s output

| Field | Value |
|---|---|
| Status | ✅ SHIPPED |
| Severity | 🟡 High |
| Submitter | hrb-app (commit `d45279f`) |
| Submitted | 2026-05-26 |
| Decision | accept — stdlib-only structured-text insert; composes with PAPER-77 part 2 (2026-05-26) |
| Decided on | 2026-05-26 |
| Shipped in | mgf-common (this commit) |

**Concern.** `catch-up`'s summary says "N finding(s) have mechanical fixes. Re-run with `--apply` to apply." That promise covers two of the three findings I hit on hrb-app (the pin bump + the TS-25 marker insertion both apply mechanically — clean experience). The TS-22..27 "federation test supervisor not fully wired" finding is **not** `--apply`-able, even though its remediation is just as mechanical:

- Add `mgf-test-supervisor @ git+ssh://…` to `[project.optional-dependencies].dev`.
- Add `allow-direct-references = true` under a new `[tool.hatch.metadata]` section (if hatchling is the build backend).
- Add `timeout = 30 / timeout_method = "thread" / faulthandler_timeout = 60` to `[tool.pytest.ini_options]`.

The first two are TOML inserts; the third is also a TOML insert. All three could be applied by the same TOML-editing infra that the pin-rewrite finding already uses. Today, however, they get printed as multi-line prose with code-block "add this to that section" instructions, and the user is expected to hand-edit.

**Why it matters.** This is the classic "mechanical fix that isn't actually mechanical" friction. The promise of `--apply` is "I read the report, I trust the fixes, one command and it's done." When two of three fixes apply and the third requires `Read` → `Edit` → `Read` → `Edit` → `Edit` cycles plus a `uv sync` plus a self-check, the consumer (agent or human) loses the rhythm. It also means the printed prose has to stay perfectly current with the supervisor's adoption guide — drift between catch-up's instructions and the canonical README is itself a maintenance liability.

For hrb-app's pass today: of ~10 minutes spent on the TS-22..27 finding, ~7 were the round-trip of "read the finding, hand-edit pyproject in three places, re-sync, self-check, debug PAPER-77, re-sync, re-self-check." `--apply` would have collapsed that to one command + the self-check.

**Suggested shape.** Make the TS-22..27 finding `--apply`-able. The three sub-fixes are all idempotent TOML inserts:

```python
def _apply_supervisor_wiring(target_dir: Path) -> None:
    pyproject = _load_pyproject(target_dir)

    # 1. Dev dep.
    dev_extras = pyproject.setdefault("project", {}).setdefault("optional-dependencies", {}).setdefault("dev", [])
    if not any(_ref_matches_supervisor(line) for line in dev_extras):
        dev_extras.append("mgf-test-supervisor @ git+ssh://git@codeberg.org/magogi-admin/mgf-test-supervisor.git")

    # 2. Hatch direct-refs (only if hatchling is the build backend).
    if _uses_hatchling(pyproject):
        pyproject.setdefault("tool", {}).setdefault("hatch", {}).setdefault("metadata", {})["allow-direct-references"] = True

    # 3. Pytest timeout config.
    pytest_ini = pyproject.setdefault("tool", {}).setdefault("pytest", {}).setdefault("ini_options", {})
    pytest_ini.setdefault("timeout", 30)
    pytest_ini.setdefault("timeout_method", "thread")
    pytest_ini.setdefault("faulthandler_timeout", 60)

    _save_pyproject(target_dir, pyproject)
```

Plus (per PAPER-77) the matching `pytest-timeout>=2.3` dev dep, or pull it transitively through the supervisor.

Optionally: print a follow-up reminder after `--apply` ("run `uv sync` then `mgf-test-supervisor --self-check`"), since the `--apply` path can't do those itself in an editable-install context.

**Cost if deferred past v1.0.** Per-adoption × every new federation sibling = sustained per-onboarding friction. Cheap to write once; expensive to repeat N times.

**Decision rationale.** Accept the structured-text insert pattern. Mirrors `_apply_add_federation_markers` (PAPER-69) for consistency — same backup-first + atomic-write discipline + same multi-case decision tree for "section exists vs not." Composes with PAPER-77 part 2: the inserted dev-deps include `pytest-timeout` so the apply path can't produce the silent-CRASHED footgun.

**Retrospective.** Shipped 2026-05-26 (this commit). New function `_apply_supervisor_wiring(target_dir)` in `src/mgf/common/cli/_catch_up.py`. Three idempotent sub-fixes wrapped in "is it already present?" checks so re-running is always safe:

1. **Dev deps** — adds `mgf-test-supervisor @ git+ssh://...` + `pytest-timeout>=2.3,<3` to `[project.optional-dependencies].dev`. Three layout cases handled (existing multi-line array / section-without-key / no section at all) via three helper functions: `_insert_into_dev_extras`, `_insert_hatch_direct_refs`, `_insert_pytest_timeout_keys`.
2. **Hatch direct-references opt-in** — adds `[tool.hatch.metadata]\nallow-direct-references = true` when build-backend is hatchling. Skipped for setuptools/poetry/other backends.
3. **Pytest timeout config** — adds `timeout = 30`, `timeout_method = "thread"`, `faulthandler_timeout = 60` to `[tool.pytest.ini_options]`. Reuses the create-section-if-missing logic from PAPER-69.

Shared module-level constants `_SUPERVISOR_DEV_DEPS` + `_SUPERVISOR_PYTEST_KEYS` ensure the audit's printed remediation and the apply-side inserts use the same source-of-truth strings.

The audit attaches `fix=_apply_supervisor_wiring` to the warn finding, so the existing `--apply` infrastructure picks it up. After-apply summary reminds the consumer to run `uv sync` + `mgf-test-supervisor --self-check` (steps the apply path can't run itself in an editable-install context).

7 unit tests added (`TestApplySupervisorWiring`):
- Full wiring from minimal hatchling pyproject (all 3 sub-actions ran).
- Non-hatchling skips the hatch block.
- Idempotent re-run is no-op + no second backup.
- Partial existing state adds only missing pieces.
- No-pyproject graceful path.
- Audit attaches the fix-callable correctly.
- Audit message mentions pytest-timeout (PAPER-77 part 2 cross-check).

3 pre-existing `TestTestSupervisorAdoptionCheck` tests updated for the new 3-signal audit (the helper now takes `pytest_timeout_dep` as a third toggle); all 8 in that class pass.

Live demo:
```
$ mgf-common catch-up --path /tmp/fresh_project --apply
  ℹ️  Federation test supervisor (TS-22..TS-27): wired supervisor: added 2 dev dep(s): [...], added `[tool.hatch.metadata] allow-direct-references = true`, added 3 pytest config key(s): ['timeout', 'timeout_method', 'faulthandler_timeout']. Now run `uv sync` + `mgf-test-supervisor --self-check` to verify. (backup: pyproject.toml.bak.1779786566)
```

Full pytest: 1788 passed (was 1780; +8 net).

---

#### <a id="PAPER-72"></a>`PAPER-72` — `docs/CLAUDE.md` project-specific section silently goes stale on pin bumps; `catch-up` doesn't notice

| Field | Value |
|---|---|
| Status | ✅ SHIPPED (fix #2); fix #1 was a no-op |
| Severity | 🟡 High |
| Submitter | hrb-app (commit `d45279f`) |
| Submitted | 2026-05-26 |
| Decision | accept fix #2 (catch-up audit); fix #1 (scaffold template) was a no-op — template doesn't restate pins (2026-05-26) |
| Decided on | 2026-05-26 |
| Shipped in | mgf-common (this commit) |

**Concern.** The `init-project` scaffolder renders a "Project-specific lookup" section BELOW the auto-managed `END mgf-common auto-generated` marker in `docs/CLAUDE.md`. That section, as scaffolded, restates the pin facts verbatim:

```markdown
- **Domain library:** `mgf-hrb` (sibling project at ...). Pinned `>=0.1,<0.2`.
- **Cornerstone:** `mgf-common` (pinned `>=0.38,<0.39`).
```

(Excerpt from hrb-app's `docs/CLAUDE.md` lines 316-318, scaffolded 2026-05-24 against mgf-common 0.38.)

`catch-up` then bumps the actual pin in `pyproject.toml` (per PAPER-65 / -68 — landed and working). But:
- the pyproject is now `mgf-common>=0.40.0,<0.41`,
- the auto-managed block in CLAUDE.md correctly refreshes,
- the prose **below** the END marker still says `>=0.38,<0.39`.

Two pin bumps later, the CLAUDE.md's project-specific section is two MINORs behind the actual pin and the first agent to grep CLAUDE.md for the federation pin gets a wrong answer.

**Why it matters.** The project-specific section is the load-bearing context for a returning agent — it answers "what does this project actually consume?" That a federation tool (catch-up) routinely modifies `pyproject.toml` pins while a sibling federation tool (init-project) scaffolds those same pins as static prose in CLAUDE.md is a direct contradiction of single-source-of-truth (the same discipline the federation enforces for its own standards docs). The end state is that hrb-app's CLAUDE.md misinforms any agent that trusts it.

The drift happens silently — catch-up runs green ✅ on `docs/CLAUDE.md` ("exists with current auto-managed block") even when the project-specific section's pin literals are months out of date.

**Why this is the library's problem, not the project's.** The pin restatement isn't an arbitrary user choice — it's literally what the scaffolded template put there. The template's failure mode is "user fills in domain context" + "user inadvertently copies pin numbers from pyproject," and the federation has the standing tool (catch-up) that already manipulates those exact pin literals elsewhere.

**Suggested shape.** Two complementary fixes (do both, IMO):

1. **Don't restate the pin in the project-specific scaffolded template.** Replace literal pin numbers with "see `pyproject.toml`" cross-references:

   ```markdown
   - **Domain library:** `mgf-hrb`. Pin window declared in `pyproject.toml`.
   - **Cornerstone:** `mgf-common`. Pin window declared in `pyproject.toml`.
   ```

   The scaffold is the one place to break the cycle. Anything not in the scaffold is user-authored — and users authoring pin literals in their own prose can fairly be expected to maintain them; the federation only owns what it scaffolded.

2. **Have `catch-up` warn (not fix — that's user prose) when CLAUDE.md prose mentions a pin literal that differs from pyproject's actual pin.** Cheap regex scan over `docs/**/CLAUDE.md` for lines matching `mgf-(common|hrb|http|fastapi|sqlalchemy|alembic|django|test-supervisor)\s+(pinned\s+)?(>=|>|==|~=)`, parse, compare to pyproject, surface as a `⚠️` finding ("CLAUDE.md L317 says `>=0.38,<0.39`; pyproject says `>=0.40.0,<0.41`"). Not `--apply`-able (it's prose); the warning is enough.

**Cost if deferred past v1.0.** Compounds per pin bump × every consumer that scaffolded with prose pins. Today: just hrb-app. Tomorrow: every project scaffolded through init-project's current template that bumps a federation dep. Cheaper to fix the template once than to clean up N projects' prose post-1.0.

**Decision rationale.** Investigated fix #1 (template) and found it's a no-op: mgf-common's current `init-project` template's project-specific section is just a placeholder ("Add project-specific facts here as the project grows...") — it doesn't restate pin literals. The "Domain library / Cornerstone pinned >=0.38,<0.39" lines in hrb-app's `docs/CLAUDE.md` were added during hrb-app's hand-editing, not by the scaffolder. The agent inferred (reasonably) that the template's failure was responsible; the actual failure mode is "user fills in prose with pin literals that then drift."

Fix #2 (the audit) IS the right shape and is what shipped. It addresses the underlying problem regardless of WHERE the pin literals came from — scaffold template, copy-paste, hand-authoring, or LLM-generated. The audit's read-only "warn with line number" output is sufficient (no apply path needed because prose is user-authored).

**Retrospective.** Shipped 2026-05-26 (this commit). New audit `_check_claude_md_pin_literals(target_dir)` in `src/mgf/common/cli/_catch_up.py`. The flow:

1. Locate `docs/claude/CLAUDE.md` (DOC-07 canonical, post-PAPER-74) or `docs/CLAUDE.md` (legacy). Canonical wins when both exist.
2. Strip the `<!-- BEGIN mgf-common -->`…`<!-- END mgf-common auto-generated -->` auto-managed block — catch-up controls that text; only user-authored prose is audited.
3. Regex-match federation lib names (`mgf-common`, `mgf-hrb`, `mgf-http`, `mgf-fastapi`, `mgf-django`, `mgf-sqlalchemy`, `mgf-alembic`, `mgf-apiprobe`, `mgf-test-supervisor`) followed by explicit `>=X.Y[,<X.Z]` pin constraints. Up to 60 chars of intervening context (parens, "pinned", backticks) accepted.
4. Parse pyproject's actual pins from `[project].dependencies` + `[project.optional-dependencies].*`.
5. Compare each prose claim against the pyproject. Drift → warn with the line number, the claimed pin, and the actual pin.

Safety:
- Bare version mentions ("0.40.0" without `>=`) are NOT flagged (could be release notes / changelog text).
- Federation libs not declared in pyproject are NOT flagged (could be a "we plan to add this" note).
- Auto-managed block is stripped before scanning — catch-up's own templating can't false-positive against itself.

9 unit tests in `TestCheckClaudeMdPinLiterals`:
- `test_no_pin_literals_returns_ok` — prose uses cross-references.
- `test_matching_pin_literal_returns_ok` — prose matches pyproject.
- `test_drifted_pin_literal_warns_with_line_number` — the canonical PAPER-72 scenario.
- `test_legacy_layout_also_audited` — works for `docs/CLAUDE.md` too.
- `test_canonical_path_wins_when_both_present` — PAPER-54 precedence honored.
- `test_auto_block_text_not_audited` — auto-managed block ignored.
- `test_no_claude_md_returns_ok` — graceful no-op.
- `test_unknown_lib_skipped_not_warned` — `mgf-foo` ignored.
- `test_lib_not_in_pyproject_skipped` — forward-looking refs OK.

Audit row wired into both `findings` and `verify_findings` (so `--apply` clean-up logic sees it as already-resolved when prose is updated by hand).

Full pytest: 1797 passed (was 1788; +9 net).

**Future deferred.** The submitter also asked for "have catch-up warn (not fix — that's user prose) when CLAUDE.md prose mentions a pin literal that differs from pyproject's actual pin" — that's literally what shipped. The complementary "scaffold template doesn't restate pin literals" turned out to be a non-issue today; if a future template revision DOES re-introduce pin restatements, the new audit will catch them immediately.

---

#### <a id="PAPER-71"></a>`PAPER-71` — broker-binary freshness check (the `tests/_broker.py` template) counts `*_test.zig` as build inputs; false-positive on test-only edits

| Field | Value |
|---|---|
| Status | ✅ SHIPPED (sibling-side, in mgf-hrb) |
| Severity | 🟢 Nice-to-have |
| Submitter | mgf-hrb sibling-#8 mid-development (mgf-hrb commit `f0671b9`) |
| Submitted | 2026-05-26 |
| Decision | accept Option 1 (filter `*_test.zig` + `tests/`); defer Options 2 + 3 (build-system fixes) (2026-05-27) |
| Decided on | 2026-05-27 |
| Scheduled for | mgf-hrb `tests/_broker.py` (paired with mgf-common 0.41.6 release wave) |
| Shipped in | mgf-hrb `tests/_broker.py` (sibling-side); pattern documented in this entry for future daemon-bearing siblings |

**Retrospective.** Shipped 2026-05-27 as a sibling-side fix in `mgf-hrb/tests/_broker.py`. New `_is_build_input(p)` helper filters out `*_test.zig` and anything under a `tests/` directory; `broker_is_stale()` calls it in the rglob loop. Existing freshness test (`tests/smoke/test_broker_freshness.py`) passes unchanged — the test exercises the happy path (fresh binary), and the filter is a pure subset operation (it can only reduce false positives, never produce them).

The fix is sibling-side because mgf-common does not yet ship a federation-shared `_broker.py` template — mgf-hrb is currently the only daemon-bearing sibling. Per TESTING.md §19.6's rule of two ("extract when ≥2 siblings adopt by copy-paste"), the template stays local to mgf-hrb until a second daemon-bearing sibling (`mgf-hrb-usb-priv`, future modules-management sibling, …) lands and adopts the pattern. At that point the freshness check graduates to a `docs/recipes/daemon-freshness.md` recipe in mgf-common with the `_is_build_input` filter baked in from day one.

No mgf-common code change; the entry is closed here so future sibling adopters of the freshness-check pattern find the lesson in one place rather than rediscovering the filter independently.

**Concern.** The freshness check that ships with the federation-recommended `tests/_broker.py` template (and any sibling that mirrors it — daemon-bearing projects in particular) compares the daemon binary's mtime against the newest file under the source tree, finding "stale" via:

```python
for src in _ZIG_SRC_DIR.rglob("*.zig"):
    m = src.stat().st_mtime
    if m > newest_src_mtime:
        newest_src_mtime = m
```

This is too aggressive in one direction: it sweeps `*_test.zig` files into the "build inputs" set. Editing a test file flips the binary to "stale" even though the test file doesn't get linked into the binary at all — `zig build` doesn't recompile the binary in response to a test-file change.

The symptom hit during mgf-hrb's Part 13 sub-phase 1B implementation today: editing `request_test.zig` to update a switch-exhaustiveness check (the new `dump_all` op needed a case in the fuzzer's switch) flipped the binary to "stale" by 316 s. Workaround: `touch hrb-code/zig-out/bin/mgf-hrb-server` between every test-file edit + gate run.

**Why it matters.** Every sibling that adopts the federation freshness-check pattern (mgf-test-supervisor inherits the shape via the `tests/_broker.py` template the standard recommends) inherits the false-positive. The friction is amplified during active development: every test-only commit gets paired with a daemon-touch step OR the gates need `--keep-going` to limp through. Either way it teaches contributors to ignore the freshness signal, which defeats the whole point of it.

**Suggested shape.** Three options, ranked by how mechanical the change is:

1. **Filter `*_test.zig` / `tests/**/*.py` out of the build-inputs glob.** Smallest change. Probably the right shape:

   ```python
   def _is_build_input(p: Path) -> bool:
       if p.name.endswith("_test.zig"): return False
       if "tests" in p.parts: return False
       return True

   for src in _SRC_DIR.rglob("*.zig"):
       if not _is_build_input(src): continue
       # rest unchanged
   ```

2. **Walk `build.zig`'s declared inputs instead of globbing the tree.** Most accurate; awkward because Zig's API for "what inputs did this target consume?" isn't stable.

3. **Compare binary mtime to a `last_built_marker` file the build script writes.** Build-system side fix: `zig build` writes `hrb-code/zig-out/.last-built` on success; the check compares against that file instead of the binary.

Option 1 closes the friction today. Options 2 + 3 are the durable form.

**Cost if deferred past v1.0.** Low — workaround is one command. But: every sibling that adopts the federation pattern re-discovers the same friction independently; centralising the fix in the standard's reference implementation prevents N adopters from carrying the same workaround.

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

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

---

#### <a id="PAPER-70"></a>`PAPER-70` — federation-wide `AuditSink` pattern: in-process audit ring buffer for every long-lived MGF service, behind a DIP-abstracted interface

| Field | Value |
|---|---|
| Status | 🆕 OPEN |
| Severity | 🟡 High (cross-cutting; every long-lived MGF service benefits) |
| Submitter | mgf-hrb sibling-#8 / Part 13 design paper (mgf-hrb `hrb-design/mgf-hrb-self-aware-introspection.md` §5.6) |
| Submitted | 2026-05-26 |
| Decision | (pending) |

**Concern.** During the mgf-hrb Part 13 design ("self-aware hardware introspection"), one of the six driving asks was *"the broker is aware of itself, near real-time — can track and follow any actions from start to end."* The mgf-hrb design proposed an in-process audit ring buffer + `audit_tail` op + `state_snapshot` op (full design at the mgf-hrb link above, §5.6). The shape is NOT mgf-hrb-specific — every long-lived MGF service (the broker; the future privileged USB helper `mgf-hrb-usb-priv`; future module-management sibling `mgf-hrb-modules-priv`; any sibling that handles requests over a socket) benefits from the same surface.

Proposing the surface itself live in `mgf-common` (similar to how `mgf.common.process` lives there and every service consumes it):

```python
# proposed: src/mgf/common/audit/__init__.py
from dataclasses import dataclass

@dataclass(frozen=True, slots=True)
class AuditRecord:
    """One operation the service handled. Shape mirrors DoctorFinding +
    test_supervisor's TestRecord — same vocabulary federation-wide."""
    seq: int                   # monotonic per-service counter
    ts_iso: str                # ISO8601 UTC
    op: str                    # service-specific verb name
    caller: str | None         # peer pid/uid/cap_id where applicable
    outcome: str               # ok | err | timeout | conflict
    err_code: str | None
    duration_us: int
    correlation_id: str | None # caller-supplied; echoed for traces

class AuditSink:
    """Behind which the storage lives. Default in-memory ring; users
    can swap in syslog / OTLP / file-writer impls. DIP applied: the
    service depends on this interface, not on any specific storage."""
    def push(self, record: AuditRecord) -> None: ...
    def tail(self, n: int) -> list[AuditRecord]: ...

def in_memory_ring(capacity: int = 4096) -> AuditSink:
    """Default impl — bounded in-process ring; ~80 bytes/record;
    capacity 4096 ≈ 320 KB; comfortably under any service's budget."""
```

**Why it matters.**

- **Consistent diagnostic vocabulary federation-wide.** Today the broker has ad-hoc stderr prints (Zig-side); mgf-common services drop `mgf.common.observability` log lines; `mgf-test-supervisor` writes JSONL TestRecords. Same conceptual shape (a thing happened, with metadata, recorded for retrospective query) — three implementations. Operators reading diagnostics across services have to learn three vocabularies. A single `AuditRecord` shape (mirroring `DoctorFinding` + `TestRecord`) is the federation pattern this whole standards regime is built on.
- **Reusable causal-trace machinery.** The `correlation_id` field lets a consumer thread a request through multiple services and reconstruct the chain. Without a federation-wide pattern, every service that wants this has to re-design.
- **Storage-impl swap is the point.** mgf-hrb's broker has a priority-order constraint (no OTLP/syslog hop on the hot path); a FastAPI service running `mgf-fastapi` does NOT have that constraint and would happily ship audit records to OTLP. The DIP-shaped `AuditSink` lets each service pick its sink.

**Suggested shape.** Three deliverables:

1. **`src/mgf/common/audit/`** — the package above. Pure stdlib (TS-23 spirit). ~150 LoC.
2. **A new federation rule** (or extend the existing `OBSERVABILITY.md`) codifying:
   - **OB-XX.** Every long-lived MGF service MUST expose an audit-tail surface (HTTP route, wire op, log file — service-specific) and the records MUST be `mgf.common.audit.AuditRecord`-shaped.
   - **OB-XX+1.** Default `AuditSink` is the bounded in-memory ring. Services with no hot-path concern MAY use a syslog / OTLP / file-writer sink instead; the shape contract is enforced regardless.
3. **`mgf-common catch-up` audit row** that checks long-lived services declare an audit sink + a tail surface. New `MGF_AUDIT_DECLARED` check.

**Cost if deferred past v1.0.** Medium. The shape is straightforward to ship; deferring means each service (broker, USB-priv helper, module-priv helper) re-implements its own audit surface, and operators learn N vocabularies instead of one. A v1.0 with three siblings shipping three audit-record shapes locks in the divergence.

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

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

---

#### <a id="PAPER-69"></a>`PAPER-69` — `mgf-common catch-up --apply` should auto-add missing federation markers to pyproject (follow-up to PAPER-66)

| Field | Value |
|---|---|
| Status | ✅ SHIPPED |
| Severity | 🟡 High (closes the silent-fail-mode the audit surfaced) |
| Submitter | mgf-common maintainer (follow-up to PAPER-66; same session, 2026-05-26) |
| Submitted | 2026-05-26 |
| Decision | accept — stdlib-only structured-text edit; create section if missing (2026-05-26) |
| Decided on | 2026-05-26 |
| Shipped in | mgf-common (this commit) |

**Concern.** PAPER-66 added the read-side audit. Its retrospective explicitly deferred the write side: *"`--apply` auto-adds missing markers into pyproject's `[tool.pytest.ini_options].markers` array. Punted because structured TOML editing without trashing comments/formatting needs either a new dep (`tomli-w`/`tomlkit`, against TS-23's stdlib-only spirit) or brittle regex over the markers block; paste-ready snippet closes the friction today without that complexity."*

**Why it matters.** The audit visibility is half the close — telling an agent "you're missing 3 markers; here's a paste-ready snippet" still requires a human (or scripted) edit. The federation contract is full automation: catch-up sees the gap → catch-up fixes the gap → re-run shows ✓. Today every other catch-up audit row that flags drift has a `fix=` callable; only this one lacked it. Closing this asymmetry is what makes "Run `mgf-common agents` and do exactly what it prints" a fully-automated workflow.

**Suggested shape.** Stdlib-only, multi-axis edit:

1. **Common case** — multi-line `markers = [...]` array exists: regex-locate, append missing entries before the closing `]`, preserve all existing entries verbatim (so project-specific descriptions survive).
2. **`[tool.pytest.ini_options]` exists but no `markers` key** (the minimal-pyproject case): insert a fresh `markers = [...]` block inside the existing section.
3. **No `[tool.pytest.ini_options]` section at all**: append a complete fresh section at end of file with all 13 markers.
4. **Single-line `markers = [...]` array** (rare; usually a hand-edited project): refuse, return clear "reformat to multi-line and re-run" message. Single-line auto-edit risks malforming the file; the cost of refusing is just one manual reformat for a rare layout.

Always: backup-first (`.toml.bak.<unix>`) + atomic write + preserve file mode. Mirrors `_apply_bump_pin`'s safety shape.

**Cost if deferred past v1.0.** Medium — additive feature, but the asymmetry between "every fix has --apply" and "this one doesn't" is a paper cut every adopter hits.

**Decision rationale.** Accept with the four-case decision tree. The structured-text edit is non-trivial but small (a few regexes + string assembly); the test surface pins every case + edge case. The "refuse on single-line layout" branch is the safety valve for unusual projects; everyone else gets full automation.

**Retrospective.** Shipped 2026-05-26 (this commit). New function `_apply_add_federation_markers(target_dir)` in `src/mgf/common/cli/_catch_up.py`. The `_check_federation_markers` warn-branch now attaches `fix=_apply_add_federation_markers` so the existing `--apply` infrastructure runs it.

`_CANONICAL_MARKER_DESCRIPTIONS` lifted to module-level (was inline in `_check_federation_markers`). Used by BOTH the read-side audit's paste-ready snippet AND the write-side apply — single source of truth for the description text every sibling's pyproject carries.

`_FEDERATION_MARKERS` now derived from `_CANONICAL_MARKER_DESCRIPTIONS.keys()` so the registration set and the description map can't drift.

Four code paths in the apply, all backup-first / atomic-write:

1. **Multi-line markers array exists** (common): regex captures open indent, contents, close indent + close-indent. Inserts missing entries with matching inner indent. Adds trailing comma to last existing entry if absent.
2. **`[tool.pytest.ini_options]` section exists but no markers key**: inserts a fresh `markers = [...]` block just after the section header.
3. **No pytest section at all**: appends a complete `[tool.pytest.ini_options]\nmarkers = [...]` section at end of file.
4. **Single-line markers array**: refuses with explanatory message; file unchanged; no backup written.

9 tests added (`TestApplyAddFederationMarkers`):
- `test_inserts_missing_markers_and_writes_backup` — common case, 3 missing markers (mgf-hrb's exact shape) added; backup exists; re-run is no-op.
- `test_inserted_markers_use_canonical_descriptions` — empty markers block; all 13 inserted with canonical descriptions verbatim.
- `test_preserves_existing_entries_verbatim` — project's custom `smoke` description + project-specific `live` marker both survive.
- `test_no_op_when_no_missing` — all 13 present → no-op + no backup.
- `test_no_pyproject_returns_noop_message` — graceful path.
- `test_single_line_layout_refuses_safely` — file unchanged, no backup.
- `test_no_pytest_section_creates_one` — minimal pyproject → fresh section appended.
- `test_pytest_section_without_markers_key_inserts_markers` — section has `timeout` etc. but no markers → markers block inserted alongside, other keys preserved.
- `test_apply_loop_picks_up_the_fix_callable` — integration: `run_catch_up` + `--apply` actually calls our fix end-to-end.

This commit also caught two pre-existing tests
(`TestRunCatchUp::test_apply_refreshes_claude_md_and_auto_cleans_backup` +
`TestRunCatchUp::test_apply_does_not_clean_pre_existing_backups`) that relied
on the post-apply verify being "clean" — they used minimal pyprojects without
`[tool.pytest.ini_options]`. The case-3 branch above (creates a fresh section)
lets the verify pass cleanly; both tests now pass without test-side changes.

**What this closes.** Symmetry: every catch-up audit row that flags drift now
carries a `fix=` callable. "Run `mgf-common catch-up --apply`" becomes a
complete one-command catch-up workflow for everything mechanical. Future
audit rows added should follow the same shape — visibility + fix as a pair.

**Future deferred.** Nothing major — the four-case tree covers every shape
the federation projects use. Edge cases (e.g., markers spread across multiple
arrays in conditional TOML — almost never seen) fall through to "refuse
safely" + manual edit, which is the right escape valve.

---

#### <a id="PAPER-68"></a>`PAPER-68` — `--check-pypi` requires explicit flag; auto-fire on editable+stale (follow-up to PAPER-65)

| Field | Value |
|---|---|
| Status | ✅ SHIPPED |
| Severity | 🟢 Nice-to-have (UX refinement; removes a flag-typing step) |
| Submitter | mgf-common maintainer (follow-up to PAPER-65; same session, 2026-05-26) |
| Submitted | 2026-05-26 |
| Decision | accept — tristate `--check-pypi` / `--no-check-pypi` with default auto-fire on editable+stale (2026-05-26) |
| Decided on | 2026-05-26 |
| Shipped in | mgf-common (this commit) |

**Concern.** PAPER-65's read-only path required `--check-pypi` to opt in. PAPER-65's retrospective named the deferred follow-up: *"auto-enabling `--check-pypi` when the editable-install case fires (no need for an explicit flag) … Held back to keep network calls gated to explicit consumer intent; auto-enabling would surprise consumers running catch-up in air-gapped envs."*

**Why it matters.** The editable-install + stale-pin case is the exact friction PAPER-65 closes. Requiring `--check-pypi` to fire the closer is double work: every agent encountering the friction has to remember the flag exists, read the tip-line message, re-run with the flag, then finally see the answer. The friction PAPER-65 removed got moved one step earlier (now it's "remember the flag exists") rather than eliminated.

**Suggested shape.** Tristate semantics that preserve air-gapped opt-out:

| Flag combination | `check_pypi` value | Behaviour |
|---|---|---|
| (default — neither flag) | `None` | Auto: fire PyPI query when editable+stale; skip otherwise. |
| `--check-pypi` | `True` | Force-enable the query (for non-editable contexts or scripted use). |
| `--no-check-pypi` | `False` | Suppress the query unconditionally (air-gapped CI opt-out). |

Default behaviour for the common case (developer machine, editable install, stale pin) becomes "auto-magic" — the agent sees the closer immediately. Air-gapped CI passes `--no-check-pypi` once in its `.woodpecker.yml` and the lookup never fires.

**Cost if deferred past v1.0.** Low — additive flag change; consumers passing `--check-pypi` explicitly still get the same behavior. The new `--no-check-pypi` is purely additive.

**Decision rationale.** Accept the tristate. The PyPI query was already graceful-on-failure (5s timeout, fallback message); the only reason to keep the explicit flag was air-gapped opt-out — and `--no-check-pypi` provides that more cleanly than "you have to opt IN to network calls by default."

**Retrospective.** Shipped 2026-05-26 (this commit). `--check-pypi` flag changed from `action="store_true"` (default False) to `action="store_const", const=True, default=None`. New flag `--no-check-pypi` (`action="store_const", const=False`) for explicit opt-out. Both write to `dest="check_pypi"` so argparse's last-flag-wins semantics handle "both passed" cases naturally (test pinned).

`_check_pyproject_pin` signature: `check_pypi: bool = False` → `check_pypi: bool | None = None`. The editable-install branch now uses `should_query_pypi = check_pypi is not False` — True or None both fire the lookup; only explicit False suppresses.

5 unit tests added/updated:
- Renamed `test_check_pypi_false_shows_default_manual_remediation` → `test_check_pypi_false_explicit_opt_out_no_query`; verified the air-gapped flavour of the message + that the PyPI query is NEVER called when `check_pypi=False`.
- New `test_check_pypi_none_default_auto_fires_on_editable`: the friction-removing test — `check_pypi=None` + editable + stale → query fires, "safe to rewrite" message, `fix=` attached.
- New `test_check_pypi_none_default_no_query_when_not_editable`: auto-mode does NOT fire when install isn't editable (non-editable path has its own `fix=` already).
- Renamed `test_check_pypi_flag_parses` → `test_check_pypi_flag_parses_tristate`: pins the three states.
- New `test_no_check_pypi_overrides_when_both_passed`: argparse last-flag-wins for both orderings.

Existing `TestQueryPyPILatestVersion` × 4 + `TestCheckPyPIFlag` × 3 unchanged. All 12 PyPI-related tests pass.

The `run_catch_up` call already passed `args.check_pypi` through `getattr(args, "check_pypi", False)`; updated to `getattr(args, "check_pypi", None)` so the None default propagates correctly.

Future deferred: nothing — this closes the PAPER-65 follow-up axis cleanly. If someone later wants the lookup to fire in non-editable cases too (currently `--check-pypi` is a no-op there because the non-editable rewrite path has all the info it needs from `__version__`), that's a new concern with a new paper.

---

#### <a id="PAPER-67"></a>`PAPER-67` — `mgf-common agents` briefing's `WF-01..WF-NN` range is hand-maintained; derive from `WORKFLOWS.md` at briefing-render time (follow-up to PAPER-64)

| Field | Value |
|---|---|
| Status | ✅ SHIPPED |
| Severity | 🟢 Nice-to-have (durable shape; closes PAPER-64's option 2) |
| Submitter | mgf-common maintainer (follow-up to PAPER-64; same session, 2026-05-26) |
| Submitted | 2026-05-26 |
| Decision | accept — auto-derive WF range; keep TS-22..TS-27 curated subrange with validation (2026-05-26) |
| Decided on | 2026-05-26 |
| Shipped in | mgf-common (this commit) |

**Concern.** PAPER-64's quick-fix replaced `WF-01..WF-11` with `WF-01..WF-12` in 5 sites by hand. Same drift will recur the next time a WF rule lands. PAPER-64's own retrospective named this: *"option 2 (derive rule count from WORKFLOWS.md at briefing-render time) is the right v1.x follow-up but warrants its own paper."* This is that paper.

**Why it matters.** Hand-maintained `X-NN..X-MM` ranges drift silently. The briefing is the most-frequently-read federation surface (every new agent runs `mgf-common agents`); when its claims are wrong, agents learn to distrust everything else in the briefing. Drift becomes corrosive in ways measured in cumulative agent-trust, not in any single rendered surface.

**Suggested shape.** Auto-derive the WF range; keep TS-22..TS-27 hardcoded.

- `_WF_MAX = _max_rule_id("WORKFLOWS", "WF") or 12` at module-import time. New helper `_max_rule_id(doc_stem, prefix)` reads the standards doc (via `_resolve_standards_dir()` from `_standards.py` — already exists), regex-matches `id="{prefix}-NN"`, returns max NN.
- F-string interpolation: `... rules WF-01..WF-{_WF_MAX:02d} ...`.
- Fallback to hardcoded 12 if standards dir can't be located (broken briefing would break every Magogi project's onboarding; never let that happen).
- TS-22..TS-27 stays hardcoded because it's a *curated subrange* of test-supervisor rules. TS-28 might land inside the cluster (extending it) or outside (unrelated TS rule); maintainer call. Validation test asserts every rule in the curated range actually exists in TESTING.md.

**Drift signal becomes CI failure.** With dynamic rendering, when WF-13 lands and the briefing auto-bumps to `WF-01..WF-13`, the existing STARTHERE.md pin test (`test_briefing_matches_starthere`) fails — STARTHERE still says `WF-12`. That test failure IS the maintenance signal; the maintainer responds by updating STARTHERE. No silent drift possible.

**Cost if deferred past v1.0.** Low — the change is internal to `_agents.py`; no external API affected. AGENTS_BRIEFING stays a `Final` constant from importers' perspective.

**Decision rationale.** Accept the auto-derive for WF (full-range references like this), keep manual for curated subranges like TS-22..TS-27. The two-axis approach matches the actual federation taxonomy: some references span a doc's full range (auto-derivable); some are curated clusters (need human judgment about cluster membership when new rules land).

**Retrospective.** Shipped 2026-05-26 (this commit). New helper `_max_rule_id(doc_stem: str, prefix: str) -> int | None` in `src/mgf/common/cli/_agents.py`. Computes max rule ID by reading the packaged standards doc via `_resolve_standards_dir()`. Returns None on any failure (doc not found, OSError, no matches); callers fall back to hardcoded defaults.

Two module-level constants computed at import time:
- `_WF_MAX = _max_rule_id("WORKFLOWS", "WF") or 12` → auto-tracks WORKFLOWS.md.
- `_TS_SUPERVISOR_RANGE = "TS-22..TS-27"` → curated subrange.

AGENTS_BRIEFING uses f-string interpolation for both. The STARTHERE.md pin test still works because STARTHERE was updated to match the rendered output (currently `WF-01..WF-12`).

3 unit tests added (`TestDynamicRuleRanges`):
- `test_wf_range_matches_workflows_md_rule_count`: asserts `_WF_MAX` equals actual max ID in WORKFLOWS.md. When WF-13 lands and someone forgets to update the dynamic-render code, this fails first.
- `test_fallback_to_12_when_doc_unreadable`: asserts the helper returns None when standards dir is unresolvable (mocked); the `or 12` fallback in the constant keeps the briefing renderable.
- `test_ts_supervisor_range_rules_all_exist_in_testing_md`: asserts TS-22..TS-27 all exist in TESTING.md. Catches accidental rule deletion or curated-range stale.

Future deferred: the same auto-derive approach for other curated/full ranges (AP-NN, EH-NN, etc.) if any of them get referenced in user-facing prose. Today only WF is referenced this way; the helper is reusable when others need it.

---

#### <a id="PAPER-66"></a>`PAPER-66` — `mgf-common catch-up` audits the test-supervisor dep + pytest timeout but doesn't audit marker-set completeness against the federation 13-marker taxonomy

| Field | Value |
|---|---|
| Status | ✅ SHIPPED (read-only path); apply-path deferred to follow-up |
| Severity | 🟡 High (silent gap; bites every new adopter) |
| Submitter | mgf-hrb sibling-#8 follow-up (mgf-hrb commit `104ce1b`) |
| Submitted | 2026-05-26 |
| Decision | accept (read-only check + canonical snippet; `--apply` marker-add deferred — 2026-05-26) |
| Decided on | 2026-05-26 |
| Shipped in | mgf-common (this commit) |

**Concern.** `mgf-common catch-up` has a row for "Federation test supervisor (TS-22..TS-27)" that checks:

- `mgf-test-supervisor` is in `[project.optional-dependencies].dev`
- `[tool.pytest.ini_options].timeout_method = "thread"` is set

But it does NOT check that `[tool.pytest.ini_options].markers` is a superset of the federation 13-marker taxonomy declared in `mgf-common/docs/standards/TESTING.md` §13.11 (smoke / e2e / integration / contract / property / regression / fuzz / concurrency / conformance / use_case / perf / stress / slow). mgf-hrb shipped with 10 of the 13 markers (missing `integration`, `concurrency`, `slow`) for ~24h after the supervisor extraction until the sibling-#8 migration agent noticed manually. A test with `@pytest.mark.integration` would have been treated as a typo by `--strict-markers` and failed pytest collection with no hint that this is a federation marker the project just hasn't registered yet.

**Why it matters.**

- **The cost is per-adopter.** Every sibling that adopts the supervisor will eventually hit this if they don't audit by hand. Today the only way to catch the drift is to grep your own pyproject against another sibling's, which assumes you know to do so.
- **It compounds with PAPER-62.** When PAPER-62 ships (`MARKER_TIER` gets `integration: 2` and similar), the supervisor will compute a real tier for `integration` tests — but only if the marker is registered in the project's pyproject. Catch-up should ensure BOTH halves are present.
- **`--strict-markers` makes the failure mode silent then loud.** A test that should have run as `integration` is rejected at collection time with `PytestUnknownMarkWarning → Error` (per TS-04 `filterwarnings = ["error"]`). The contributor sees "unknown marker" rather than "your project's pyproject is missing a federation-standard marker."

**Suggested shape.** Extend the "Federation test supervisor" catch-up row to also do:

```python
# pseudo-code, inside the existing supervisor check
canonical_markers = {
    "smoke", "e2e", "integration", "contract", "property",
    "regression", "fuzz", "concurrency", "conformance",
    "use_case", "perf", "stress", "slow",
}
project_markers = set(parse_pyproject_markers(pyproject_path))
missing = canonical_markers - project_markers
if missing:
    print(
        f"  ⚠️  pyproject.toml — federation marker taxonomy\n"
        f"      MISSING {len(missing)} of 13 markers: "
        f"{sorted(missing)}. Per `mgf-common standards testing` §13.11. "
        f"Re-run with `--apply` to add them with canonical descriptions."
    )
```

`--apply` adds the missing entries by reading the canonical descriptions from `mgf-common/docs/standards/TESTING.md` §13.11 (already parseable; the table is well-formed markdown). Each appended marker carries the same tier-hint comment any sibling shows so the project's marker block reads the same as everyone else's.

Bonus: if the project HAS markers that are NOT in the canonical 13, catch-up could note them ("project carries 2 markers not in the federation taxonomy: `foo`, `bar` — intentional?") — gentler tone since project-specific markers are legitimate, but the visibility helps an agent decide whether to file a federation-taxonomy-extension paper.

**Cost if deferred past v1.0.** Medium. The gap is currently absorbed by "each adopter agent eventually notices"; v1.0 will lock in the contract that catch-up = "the audit is complete." A v1.0 catch-up that misses this category teaches consumers the audit's silent-on-X behaviour is acceptable, and the same pattern leaks into future audits.

**Decision rationale.** Accept the read-only audit + the paste-ready canonical snippet for missing markers. The `--apply` marker-add path (auto-insert into pyproject `[tool.pytest.ini_options].markers` array) is deferred to a follow-up paper because structured TOML editing without trashing comments/formatting is more involved than the existing `--apply` substitution patterns can borrow — it would either need `tomli-w`/`tomlkit` as a new dep (against TS-23's stdlib-only spirit) or careful regex over the markers block (brittle). Paste-ready snippet closes the friction loop today without that complexity.

**Retrospective.** Shipped 2026-05-26 (this commit). New canonical constant `_FEDERATION_MARKERS` in `src/mgf/common/cli/_catch_up.py` (frozenset of 13). New helper `_extract_pyproject_marker_names(target_dir)` parses `[tool.pytest.ini_options].markers` and extracts each entry's name (`"name: desc"` → `"name"`; bare-name entries also accepted). New audit row `_check_federation_markers(target_dir)` returns `_Finding`:
- **ok**: all 13 registered. Extras (project-specific markers) noted gently — they're legitimate.
- **warn**: missing one or more; message lists the missing names + a paste-ready snippet with canonical descriptions matching every sibling's pyproject markers block + cross-ref to TS-25 / §13.11.

`slow` is required despite being deprecated (per TS-18) because projects in flight may have historical `@pytest.mark.slow` uses; rejecting registration would be more disruptive than the deprecation itself. The constant has a comment explaining this.

Wired into both `run_catch_up`'s findings list and `verify_findings`. 8 unit tests added (`TestFederationMarkersCheck`) covering: all-13 / missing-3 (mgf-hrb's exact pre-migration shape) / missing-13 / extras-noted / extras-with-missing / no-pyproject / bare-name parsing / 13-marker-count pin (regression guard against constant drift).

Cross-cuts with PAPER-62 (MARKER_TIER for `integration` + `slow`): when 62 ships, both halves of the contract are enforced — `_FEDERATION_MARKERS` (this paper, the REGISTRATION audit) + `MARKER_TIER` (PAPER-62, the TIER MAPPING). The two-axis audit eliminates the silent-fall-through-to-DEFAULT_TIER class of failures.

Deferred to follow-up paper: `--apply` writes the missing entries into pyproject. See decision rationale; needs a TOML-editing strategy decision.

---

### `PAPER-85` — Sub-agent pytest invocations get denied by Claude Code's permission policy; federation-wide release-wave parallelism collapses to sequential

| Field | Value |
|---|---|
| Status | ✅ SHIPPED (all three paths) |
| Severity | 🟡 High (degrades release-cadence efficiency, not consumer safety) |
| Submitter | mgf-common maintainer (self-observed during the 6-sibling cohort-walk release wave, 2026-05-26) |
| Submitted | 2026-05-26 |
| Decision | accept all three paths; ship #1 immediately, ship #2 + #3 as paired follow-up in 0.41.4 (2026-05-26) |
| Decided on | 2026-05-26 |
| Shipped in | `.claude/settings.local.json` (path #1, local-only) + mgf-common 0.41.4 (paths #2 + #3 — federation standards REL-10/11/12 + recipe) |

**Concern.** When a federation-wide release wave needs to ship N sibling repos in parallel (e.g., the PAPER-84 cohort-walk that shipped mgf-http, mgf-fastapi, mgf-django, mgf-sqlalchemy, mgf-alembic, mgf-apiprobe in one batch), spawning N sub-agents — one per repo — is the natural shape. Each agent reads its pyproject, applies edits, runs pytest, commits, tags, pushes, builds, publishes.

In practice, **5 of 6 agents in the 2026-05-26 wave halted at the pytest step** with `Permission to use Bash has been denied`. Sub-agents inherit the parent session's permission policy but the policy didn't pre-authorize pytest invocations in arbitrary sibling repos. The mgf-http agent reported: *"three attempts at the Bash tool returned `Permission to use Bash has been denied`. I tried `cd ... && uv run pytest`, `uv run pytest -q` without `--tb=short`, and `uv --directory ... run pytest -q` — all denied."*

The release wave still landed — the maintainer ran pytest for each repo in the parent session (where permission was already granted), then completed commit + tag + push + build + publish per sibling. But the work that should have been ~10 minutes of parallel agent execution became ~30 minutes of sequential maintainer-driven steps.

**Why it matters.** Federation-wide release waves are a recurring pattern. Every minor mgf-common bump that needs a cohort-walk hits the same shape: N siblings, each needing the same mechanical edits + pytest verification + release. Parallelism is what makes the wave affordable. The agent-permission gap silently converts parallel-work into sequential-maintainer-work, with no operator-visible warning until the first agent reports back stalled.

**Concrete evidence (2026-05-26 cohort walk).** Six sub-agents spawned at ~02:50 UTC. Outcomes:
- mgf-http agent: stalled at step 8 (pytest), reported back, maintainer ran pytest + completed release.
- mgf-fastapi agent: same.
- mgf-django agent: stalled but pushed Codeberg side (commit + tag) anyway; build/publish blocked.
- mgf-sqlalchemy agent: same shape as mgf-http.
- mgf-alembic agent: same shape.
- mgf-apiprobe agent: same shape.
Net: every sibling required maintainer intervention before publish.

**Suggested shape — three paths, lowest-to-highest investment.**

1. **Pre-authorize pytest in `.claude/settings.local.json`.** Sub-agents inherit the parent's permission policy; adding pytest invocation patterns to the always-allow list removes the bottleneck for every future release wave. ~2 min one-time setup; zero ongoing cost. Recommended first move.

2. **Agent prompts carry a pytest-denial fallback clause.** "If pytest is denied 3 times, skip step 8, report edits as ready-to-verify, hand back without committing." Preserves the safety invariant (no publish without verification) but turns a hard stop into a clean handoff. Defense-in-depth — worth doing even if #1 lands, because the permission policy might miss edge cases (e.g., a sibling that uses a non-standard pytest invocation shape). ~5 min per agent prompt template.

3. **Structural: separate "edit" agents from "verify-and-ship" steps.** Phase A (parallel agents): edits only — pyproject + CHANGELOG + `__init__.py` version + `uv.lock`. No pytest. No commit. No publish. Each agent reports "ready for verification." Phase B (maintainer, sequential): one verify-and-ship loop — pytest + commit + tag + push + build + publish per sibling. ~30 min one-time formalization in `WORKFLOWS.md` / `RELEASING.md`. Makes the parallelism + sequential-verification split structural instead of accidental.

**Recommendation:** Land path #1 immediately. Adopt path #2 next time the maintainer spawns release agents (one-shot prompt-template update). Defer path #3 until the next cohort drift surfaces empirical data on whether structural separation is worth the formalization.

**Cost if deferred past v1.0.** Per-release-wave × every cohort walk = recurring 20-minute drag. Cheap to land; expensive to leave.

**Decision rationale.** Adopt path #1 as the immediate fix — minimum-cost, maximum-leverage. Paths #2 + #3 are documented as available; they compose, not compete. The federation's release standards corpus (`WORKFLOWS.md` / `RELEASING.md`) can codify path #3's edit-vs-ship phase split when there's a second cohort-walk data point.

**Retrospective.** Shipped 2026-05-26 (this commit) — path #1 only.

Added the following patterns to `.claude/settings.local.json`'s permissions allow-list:
- `Bash(env -u VIRTUAL_ENV uv run pytest *)` — the canonical federation invocation.
- `Bash(env -u VIRTUAL_ENV uv run pytest)` — bare form.
- `Bash(uv run pytest *)` — without the `env -u VIRTUAL_ENV` prefix (some agents drop it).
- `Bash(uv run pytest)` — bare form.
- `Bash(timeout * uv run pytest *)` — when wrapped in `timeout` per the federation's "kill at 180s" idiom.

The next cohort-walk release wave is the first real verification — agents that spawn pytest in arbitrary sibling repos should no longer trip the permission gate. If a new shape appears (e.g., `python -m pytest` or pytest-via-mtest), append it to the same allow-list.

Paths #2 + #3 stay documented above for future adoption; this paper's retrospective serves as the design artifact when the maintainer or a future agent reaches for them.

**Update 2026-05-26 — paths #2 + #3 shipped in mgf-common 0.41.4.** The maintainer's framing — *"we need to have better procedure to sync and ship this library and all siblings"* — promoted both deferred paths from "documented as available" to "shipped as federation standards rules." The cohort-walk wasn't standardised; running it ad-hoc per incident was the actual root cause that PAPER-85 + PAPER-84 both surfaced from different angles.

Three new RELEASING rules:
- **REL-10** — a `mgf-common` minor bump that breaks any sibling's pin upper-bound MUST trigger a cohort-walk release wave BEFORE the new minor is announced to consumers.
- **REL-11** — cohort waves MUST follow the Phase A / Phase B split (parallel edit-agents, then sequential verify-and-ship driven by the maintainer).
- **REL-12** — Phase A agents MUST carry a graceful pytest-denial fallback (the very pattern PAPER-85 documented).

New canonical recipe at `docs/recipes/cohort-release-wave.md` with the full procedure: when to run, Phase A agent prompt template (with the REL-12 fallback clause inline), Phase B sequential ship script, failure modes + recovery (pytest denial, pytest actual-failure, PyPI direct-ref rejection, PyPI first-time rate-limit, CDN propagation lag), wave-coordination tracker pattern, post-wave PAPER-84 verification.

Path #1's `.claude/settings.local.json` patterns stay valid — they're the local complement to the federation-level recipe. The recipe documents that the patterns are required for Phase A to run cleanly.

---

### `PAPER-78` — `mgf-common agents` is the entire onboarding mechanism but has no fallback when the install is broken

| Field | Value |
|---|---|
| Status | ✅ SHIPPED (Approach A) |
| Severity | 🟡 High |
| Submitter | iosRecovery (agent session, 2026-05-26) |
| Submitted | 2026-05-26 |
| Decision | accept Approach A (text fallback in AGENTS.md); defer Approach C (standalone CLI) to follow-up paper (2026-05-26) |
| Decided on | 2026-05-26 |
| Scheduled for | mgf-common 0.41.3 |
| Shipped in | mgf-common 0.41.3 |

**Concern.** `AGENTS.md` instructs every agent in every Magogi project: *"Run `mgf-common agents` and do exactly what it prints."* This single command is the canonical onboarding hook for the entire federation. But when the editable install of `mgf-common` is broken — the most common failure mode being a directory-rename leaving the editable `.pth` pointing at a non-existent path — the command fails with `ModuleNotFoundError: No module named 'mgf.common.cli'` and the agent has no documented fallback. Onboarding stops at the first instruction with no breadcrumb for recovery.

**Why it matters.** This breaks the federation-wide contract that `mgf-common` ships the canonical agent message. Per AGENTS.md the briefing "always works in every Magogi project with no setup and no assumptions" — that claim is false when the install is in any non-pristine state. An agent that hits the error has three bad options: (a) give up, (b) invent its own onboarding, (c) accidentally discover `STARTHERE.md` carries the same text. Options (b) and (c) violate principle 8 ("Consistency is the point") and principle 7 ("Use the federation; do not reinvent it"). The defect is meta — the mechanism that enforces federation discipline is itself the weakest link.

**Concrete evidence.**
- This session, on `iosRecovery` (`master`, commit `3393810`), `mgf-common agents` exited 1 with `ModuleNotFoundError`.
- Root cause: `.venv/lib/python3.13/site-packages/_editable_impl_mgf_common.pth` contains the single line `/home/bassam/PycharmProjects/mgf_common/src` (underscore). The actual source lives at `/home/bassam/PycharmProjects/mgf-common/src` (hyphen). The underscore directory exists but contains only `.claude/settings.local.json` + `.idea/` — empty of code.
- Forensic trail of the rename: `mgf_common/.claude/settings.local.json` contains dozens of allow-list entries like `Bash(sed -i 's|gh:magogi-admin/mgf_common|gh:magogi-admin/mgf-common|g' ...)` and `Bash(sed -i 's|/home/bassam/PycharmProjects/mgf_common/|/home/bassam/PycharmProjects/mgf-common/|g' docs/claude/settings_project.json)`. The repo was renamed from `mgf_common` → `mgf-common` but the consumer's editable install was never refreshed, and the empty husk was never deleted.
- iosRecovery itself had to chase a related rename: commit `3393810` "chore: update Codeberg URL refs to renamed mgf-common / mgf-apiprobe slugs" — same root cause class.
- `AGENTS.md` lines 7-12 promise the command "always works" and offer no fallback. `mgf.common.cli._agents.run_agents` (line 202–213) is described in its own docstring as having "no failure modes (the text is a source constant; it cannot fail to resolve)" — but the failure happens *upstream of `run_agents`*, in resolving the package.

**Suggested shape.**

- **Approach A — Text fallback in AGENTS.md (cheapest, zero code).** Add a one-line tail to AGENTS.md: *"If `mgf-common agents` fails to run, read the **For the AI agent in any Magogi project** section of `mgf-common`'s `STARTHERE.md` — it carries identical text, kept in sync by `tests/unit/cli/test_agents.py`."* This converts a hard failure into a redirect; the same test that already pins parity also pins the recovery path.
- **Approach B — `mgf-common doctor` rename-drift check.** New audit row that resolves every `_editable_impl_*.pth` in the active venv, checks the target path exists, and (if not) suggests the likely fix when a same-name-different-separator sibling exists nearby. Surfaces the defect *before* the agent hits it.
- **Approach C — Standalone fail-safe CLI.** Ship `mgf-common-agents` as a separate console script whose `main()` does `print(pathlib.Path(__file__).with_name('AGENTS_BRIEFING.txt').read_text())` — no `mgf.common.cli.*` import path, no namespace-package resolution. A second binary is the durable answer to "the primary import is broken"; it survives any future package-shape change. The text is bundled as package data, generated at build time from `_agents.AGENTS_BRIEFING`.

Recommend **A + C**: text fallback gives an immediate, zero-risk recovery path today; the standalone CLI gives the federation a binary that genuinely "always works." B is a strong adjunct but only catches the rename-drift case; A+C is shape-agnostic.

**Decision rationale.** *Pending triage.*

**Retrospective.** *Pending ship.*

---

### `PAPER-79` — No federation-aware "rename a sibling library" recipe or CLI helper

| Field | Value |
|---|---|
| Status | ✅ SHIPPED (Approach A) |
| Severity | 🟢 Nice-to-have |
| Submitter | iosRecovery (agent session, 2026-05-26) |
| Submitted | 2026-05-26 |
| Decision | accept Approach A (recipe doc) now; defer Approach B (CLI helper) and Approach C (federation registry) (2026-05-27) |
| Decided on | 2026-05-27 |
| Scheduled for | mgf-common 0.41.6 |
| Shipped in | mgf-common 0.41.6 |

**Concern.** Sibling-library renames are now an observed federation event, not a one-off. `mgf_common` → `mgf-common` (separator change) has already happened; `mgf-ubs` → `mgf-hrb` (whole-name change) is in planning. Each rename touches the same surfaces in every consumer + every sibling: the `pyproject.toml` dependency pin, the editable `.pth` in any active venv, `USERS.md` rows, `MGF_ADOPTION.md` entries, the FEDERATION.md §5 "need X → use Y" table, the sibling enumeration in `_agents.py`'s briefing text, Codeberg URL references in READMEs and CHANGELOGs, and the half-renamed stale directories under each developer's `PycharmProjects/`. There is no documented recipe and no CLI helper; every rename gets re-discovered as a sed script in someone's Claude settings file.

**Why it matters.** A rename done piecemeal leaves long-tail drift: empty husks of the old directory linger for months (`PycharmProjects/mgf_common/`, `PycharmProjects/ARCH/mgf_common/`, `PycharmProjects/ARCH/mgf_hrb/` are all visible today), editable installs silently point at nothing (root cause of PAPER-78), and the briefing text in `_agents.py` enumerates a sibling list that gradually decouples from reality (see PAPER-80). The cost grows with every consumer added. Doing this once is fine; doing it without a checklist means every future rename re-pays the discovery cost and re-introduces the same drift class.

**Concrete evidence.**
- `mgf_common/.claude/settings.local.json` — the audit trail of the previous rename, expressed as ad-hoc sed allow-list entries. The fact that this file is the *only* surviving record of how the rename was executed is the symptom.
- Empty stale dirs: `/home/bassam/PycharmProjects/mgf_common/` (only `.claude/` + `.idea/`), `/home/bassam/PycharmProjects/ARCH/mgf_common/` (empty), `/home/bassam/PycharmProjects/ARCH/mgf_hrb/` (empty).
- iosRecovery commit `3393810` "chore: update Codeberg URL refs to renamed mgf-common / mgf-apiprobe slugs" — a consumer chasing a rename across its own docs, one commit at a time.
- `mgf-common/src/mgf/common/cli/_agents.py:97-100` — hand-maintained sibling enumeration in the canonical briefing; not driven from any registry, so post-rename consistency depends on remembering to edit it.

**Suggested shape.**

- **Approach A — Recipe only.** Add `docs/recipes/sibling_rename.md` enumerating every surface and the order to sweep them (pin → docs → registry → consumers' .pth → husk cleanup). Zero new code; pure checklist. Becomes the answer when an agent or maintainer asks "how do I rename a sibling."
- **Approach B — Recipe + CLI helper.** Same recipe, plus `mgf-common migrate-sibling <old> <new> [path]` that does the mechanical sweeps the existing `mgf-common migrate` already knows how to do (import renames, pyproject pin rewrites) — extended to handle dist-name renames (hyphen-vs-underscore aware), Codeberg URL slug rewrites in markdown, and a warning when an editable `.pth` in the active venv points at the old path. Reuses the existing `--dry-run --backup` ergonomics.
- **Approach C — Federation registry as the source of truth.** A `mgf/common/_standards/federation_registry.yaml` listing every sibling with `dist_name`, `import_name`, `repo_slug`, `status` (active/renamed/archived). Renames update one row; the briefing string, FEDERATION.md §5 table, and `mgf-common catch-up` consumer checks all read from it. Composes with PAPER-80.

Recommend **A now** (cheap, immediately useful for the upcoming mgf-ubs → mgf-hrb rename), **B + C as a follow-up** once the registry-driven shape lands.

**Decision rationale.** Accepted Approach A in mgf-common 0.41.6. The recipe is pure docs — zero code risk, immediate value for the next rename event (whoever owns that work has a checklist instead of rediscovering surfaces). Approach B (CLI helper) and Approach C (federation registry) deferred — they're the right structural shape but pay for themselves only after a 3rd rename event proves the per-surface mechanical work is genuinely repetitive enough to mechanise. Today the per-rename work is small enough that the checklist suffices.

**Retrospective.** Shipped 2026-05-27 in mgf-common 0.41.6.

New `docs/recipes/sibling_rename.md` (~150 lines). Walks the rename through five phases:
- **Phase A** — Plan + announce (naming convention check, federation-wide notification, PyPI old-name reservation).
- **Phase B** — The sibling itself (pyproject `name`, source dir, CHANGELOG, internal README sweep).
- **Phase C** — `mgf-common` federation refs (`_FEDERATION_SIBLINGS`, STARTHERE.md, FEDERATION.md §5, USERS.md, standards docs, cutover docs).
- **Phase D** — Consumer-side sweep (pyproject pins, `[tool.uv.sources]` paths, active venv `.pth`, on-disk husks).
- **Phase E** — Notification + closeout.

The recipe explicitly names PAPER-78 (editable `.pth` silent footgun), PAPER-83 (uv.sources audit), PAPER-80 (`_FEDERATION_SIBLINGS` constant), and references the future Approach B/C cleanups under PAPER-79 — every cross-referenced paper has a hook back here when its surface gets touched.

Recipe index updated (`docs/recipes/README.md`) — new **Federation operations** section. Picked up the previously-unindexed `cohort-release-wave.md` while editing. A-Z list also got a `sibling_rename` entry.

---

### `PAPER-80` — Sibling enumeration in `_agents.py` AGENTS_BRIEFING is hand-maintained and silently goes stale

**Status:** ✅ SHIPPED (Approach B) in mgf-common 0.41.3. `_FEDERATION_SIBLINGS` constant in `_agents.py` drives the briefing's enumeration via f-string interpolation. Pin tests catch divergence between the constant + the rendered text + the STARTHERE.md mirror. Adding a new sibling is now a one-line update to the constant; the briefing + STARTHERE.md refresh on next test pin. 3 new tests in `TestFederationSiblings`. Approach A (Codeberg registry + composes with PAPER-79) deferred — registers as future structural work.

---

### `PAPER-80-original-concern` — (see SHIPPED note above)

| Field | Value |
|---|---|
| Status | ✅ SHIPPED (via parent Approach B) |
| Severity | 🟢 Nice-to-have |
| Submitter | iosRecovery (agent session, 2026-05-26) |
| Submitted | 2026-05-26 |
| Decision | parent SHIPPED Approach B (`_FEDERATION_SIBLINGS` constant); residual Approach A (Codeberg registry) scoped under [[PAPER-79]] |
| Decided on | 2026-05-27 |
| Scheduled for | mgf-common 0.41.3 (parent) |
| Shipped in | mgf-common 0.41.3 (parent) |

**Concern.** The canonical briefing string in `_agents.py` enumerates siblings inline: `` `mgf-http`, `mgf-fastapi`, `mgf-sqlalchemy`, `mgf-alembic`, `mgf-django`, `mgf-apiprobe`, `mgf-test-supervisor`, … ``. The trailing ellipsis acknowledges incompleteness, but the listed-vs-real gap is wide and grows whenever a sibling is added or renamed. Today there are 15 sibling-shaped directories under one developer's `PycharmProjects/` (mgf-vm and 6 mgf-vm-* workers, mgf-http, mgf-fastapi, mgf-sqlalchemy, mgf-alembic, mgf-django, mgf-apiprobe, mgf-test-supervisor, mgf-website-template, mgf-hrb) — the briefing names 7. Neither `mgf-ubs` (about to be deprecated) nor `mgf-hrb` (about to replace it) appears.

**Why it matters.** This is the canonical onboarding text — what an agent reads first to learn "what is in this federation." A stale enumeration teaches the agent the federation is smaller than it is; agent then under-uses siblings (violates principle 3 — "Use the federation; do not reinvent it") because it doesn't know the relevant sibling exists. The maintenance gap compounds on every rename: PAPER-79's mgf-ubs → mgf-hrb rename must remember to touch this string, but the string isn't visible from the rename's natural touch-points (pyproject, USERS.md, FEDERATION.md).

**Concrete evidence.**
- `src/mgf/common/cli/_agents.py:97-100`: the literal enumeration as quoted above. The string is a Python `f"""..."""` constant; nothing programmatically validates the list matches any registry.
- `STARTHERE.md:42-46`: same list, duplicated. The test `tests/unit/cli/test_agents.py` enforces parity *between the two copies*, not against any source of truth.
- Listed but not validated against existence: `mgf-fastapi`, `mgf-django`, `mgf-alembic`, `mgf-apiprobe` all exist on disk; ✓.
- Not listed but exist: `mgf-vm`, `mgf-vm-core`, `mgf-vm-sdk`, `mgf-vm-libvirt`, `mgf-vm-firecracker`, `mgf-vm-forensics`, `mgf-vm-interchange`, `mgf-website-template`, `mgf-hrb`.

**Suggested shape.**

- **Approach A — Drive from a single registry.** Composes with PAPER-79 Approach C: `_FEDERATION_REGISTRY` (frozenset or dict, declared once in code or in `_standards/federation_registry.yaml`). `_agents.AGENTS_BRIEFING` builds the sibling enumeration at import time from the registry. FEDERATION.md §5 table is generated from the same registry by a build hook. Renames update one row; both surfaces stay aligned for free.
- **Approach B — Bare CI check.** Cheaper interim: a test that compares the inline list against an explicit "expected siblings" frozenset in the same module, and fails CI when they diverge. Doesn't solve the rendering side (`STARTHERE.md` still hand-maintained, with parity enforced by the existing test_agents test) but at least the *content* of the list is anchored.
- **Approach C — Drop the enumeration entirely.** Replace the inline list with a forward reference: *"see `mgf-common standards federation` or `FEDERATION.md §5` for the full sibling catalog."* The briefing becomes shape-only; the catalog moves to one canonical location. Lowest maintenance, lowest discovery (agent has to follow one more link).

Recommend **A** if PAPER-79-C lands (free composition); otherwise **B** as the cheap immediate fix.

**Decision rationale.** *Pending triage.*

**Retrospective.** *Pending ship.*

---

### `PAPER-81` — `mgf-common catch-up --apply` rewrites the pin without the `--check-pypi` the report says is required

**Status:** ✅ SHIPPED in mgf-common 0.41.2 (composed with PAPER-84's gate). The cross-sibling consistency check is strictly more conservative than what PAPER-81 asked for: PAPER-81 wanted catch-up to honor the `--check-pypi` requirement before bumping; PAPER-84's check ALSO honors it (the PyPI lookup happens during the cross-sibling consistency scan) AND adds the cross-sibling gate. Both checks must pass before `--apply` offers the rewrite. Closes PAPER-81 by the same change. See PAPER-84 retrospective above for implementation detail.

---

### `PAPER-81-original-concern` — (see SHIPPED note above)

| Field | Value |
|---|---|
| Status | ✅ SHIPPED (via [[PAPER-84]]) |
| Severity | 🟡 High |
| Submitter | iosRecovery (agent session, 2026-05-26) |
| Submitted | 2026-05-26 |
| Decision | closed by PAPER-84's cross-sibling gate (strictly more conservative than the `--check-pypi` opt-in PAPER-81 asked for) |
| Decided on | 2026-05-27 |
| Scheduled for | mgf-common 0.41.2 (parent) |
| Shipped in | mgf-common 0.41.2 (parent) |

**Concern.** The drift report for a stale `mgf-common` pin says: *"Run with `--apply --check-pypi` to perform the rewrite; backup written."* This reads as a contract: the rewrite requires the explicit `--check-pypi` opt-in. But `--apply` alone rewrites the pin anyway. An operator who reads the report and runs the recommended *less-flagged* form (`--apply`, expecting only the OTHER mechanical fixes to land) gets the pin bump too — silently bypassing the opt-in gate they thought they had. Worse, the tool then runs its post-apply verification, decides everything is "clean," and deletes the backups — so the rewrite is unrecoverable without git.

**Why it matters.** Pin rewrites are the most consequential fix `catch-up --apply` performs. Bumping from `>=0.38,<0.39` to `>=0.41.1,<0.42` crosses three minor versions of a library that just shipped a "pre-v1.0 surface lockdown" (per mgf-common CHANGELOG v0.41.0) and a "test supervisor extraction" — non-trivial breaking changes. Operators rely on the `--check-pypi` flag wording to gate exactly this risk. When the wording is wrong, operators learn "the catch-up report's instructions don't match the tool's behaviour" — eroding trust in every other recommendation the report makes. Also, the auto-cleanup of backups on "verified clean" means the safety net the report promised ("backup written") is gone before the operator can read the diff.

**Concrete evidence.**
- Drift report line (this session, iosRecovery, 2026-05-26): *"STALE: declared `>=0.38,<0.39` but current (editable-install) mgf-common is `0.41.1`. `--check-pypi` confirms PyPI is at `0.41.1` (≥ editable `0.41.1`) — safe to rewrite to `>=0.41.1,<0.42`. Run with `--apply --check-pypi` to perform the rewrite; backup written."*
- Apply-output line (same session, `mgf-common catch-up --apply`): *"ℹ️  pyproject.toml — mgf-common pin: bumped pin to `>=0.41.1,<0.42` in /home/bassam/PycharmProjects/iosRecovery/pyproject.toml (backup: pyproject.toml.bak.1779790764)"*
- Final apply-output line: *"Verified clean — cleaned up 2 backup files (.bak.<timestamp>)."* Backups gone.
- Git diff confirms `pyproject.toml` was rewritten on the no-`--check-pypi` invocation.

**Suggested shape.**

- **Approach A — Match behaviour to the report wording (gate the pin rewrite).** Make `--apply` skip the pin row by default; require `--check-pypi` (or `--apply-pin` / `--include-pin`) to opt in. Pin bumps are the riskiest fix; opting in is the conservative default. Drift report's existing wording becomes accurate at zero communication cost.
- **Approach B — Match report wording to behaviour (drop the `--check-pypi` claim).** Rewrite the drift-report line to: *"Run with `--apply` to perform the rewrite; add `--check-pypi` to additionally verify PyPI agrees with the editable install before rewriting; backup written."* Honest about what each flag does. Cheaper but leaves the conservative default unchanged.
- **Approach C — Stop auto-cleaning backups after pin rewrites.** Whatever happens with A/B, the "Verified clean — cleaned up backups" step should retain backups when the rewritten file is `pyproject.toml` (it's the operator's lockfile-of-record; they MUST be able to diff). Possibly key on a `_RISKY_REWRITES` set; cheap to maintain.

Recommend **A + C**: gate the pin rewrite, keep its backup. B is a fallback if A breaks too many existing scripts that expected the current behaviour.

**Decision rationale.** *Pending triage.*

**Retrospective.** *Pending ship.*

---

### `PAPER-82` — No cutover docs exist for v0.35..v0.41; consumers crossing 3+ minor versions get no migration guide

**Status:** ✅ SHIPPED in mgf-common 0.41.3. New `docs/cutover/v0.35-through-v0.41.md` consolidated span-cutover doc covering the 7-minor gap. 9 per-version stub files (`v0.35.0.md` through `v0.41.2.md`) each cross-reference the span doc. CHANGELOG remains the per-release source of truth; the span doc is the migration-relevant slice. Future cutover docs revert to per-minor pattern; this stub-plus-span is the one-time retroactive fix.

---

### `PAPER-82-original-concern` — (see SHIPPED note above)

| Field | Value |
|---|---|
| Status | ✅ SHIPPED (via parent) |
| Severity | 🟡 High |
| Submitter | iosRecovery (agent session, 2026-05-26) |
| Submitted | 2026-05-26 |
| Decision | parent SHIPPED `docs/cutover/v0.35-through-v0.41.md` span doc + 9 per-version stub files. Approach C (CI gate enforcing one cutover doc per release) deferred — survives as a separate follow-up if drift recurs. |
| Decided on | 2026-05-27 |
| Scheduled for | mgf-common 0.41.3 (parent) |
| Shipped in | mgf-common 0.41.3 (parent) |

**Concern.** `docs/cutover/` ships per-release migration narratives — `v0.22.0.md` through `v0.34.0.md` plus an aggregated `federation-v0.34.0.md`. But the directory stops at `v0.34.0`, while the library is at `0.41.1`. **There are no cutover docs for v0.35, v0.36, v0.37, v0.38, v0.39, v0.40, or v0.41.** Per `STARTHERE.md` line 240: *"Per-release migration narratives live in `docs/cutover/` (v0.22 onward; pre-v0.22 in `archive/`)."* That promise is currently broken for ~7 releases — including the most recent and most impactful ones (per `CHANGELOG.md`: "Pre-v1.0 surface lockdown" in v0.41.0, "Test supervisor extraction" in v0.40.0–v0.41.0, "Codeberg-slug cutover" in v0.40.0, "Standards corpus expansion" in v0.40.0).

**Why it matters.** Cutover docs are *the canonical migration story* (STARTHERE.md line 242: "the cutover guide is the canonical migration story; `migrate` handles the mechanical parts."). Without them, a consumer crossing four minor versions of breaking changes — exactly what `mgf-common catch-up --apply` may force on them (see PAPER-81) — has no document to consult. They must (a) read all 7 CHANGELOG entries and synthesise the migration themselves, (b) discover breakage at runtime, or (c) keep the old pin and silently fall further behind the federation. None of these match the discipline the rest of the federation enforces. The recent releases are the ones most likely to break consumers, so the gap is exactly inverted — least documentation where most is needed.

**Concrete evidence.**
- `ls /home/bassam/PycharmProjects/mgf-common/docs/cutover/`:
  ```
  archive/
  federation-v0.34.0.md
  README.md
  v0.22.0.md  v0.23.0.md  v0.24.0.md  v0.25.0.md  v0.26.0.md
  v0.27.0.md  v0.28.0.md  v0.29.0.md  v0.30.0.md  v0.31.0.md
  v0.32.0.md  v0.33.0.md  v0.34.0.md
  ```
  No `v0.35.0.md`, `v0.36.0.md`, `v0.37.0.md`, `v0.38.0.md`, `v0.39.0.md`, `v0.40.0.md`, `v0.41.0.md`, `v0.41.1.md`.
- `CHANGELOG.md` for v0.41.0 includes section headers `### Test supervisor extraction (continued from 0.40.0)`, `### Pre-v1.0 surface lockdown (PAPER-67..69 + deletions)`, `### Migration (consumer-side checklist for federation projects)` — exactly the kind of breaking-change cluster that warrants a dedicated cutover doc.
- This session: iosRecovery was pinned `mgf-common>=0.38,<0.39` and catch-up bumped to `>=0.41.1,<0.42`. The operator following the prompt is asked to cross v0.39 → v0.40 → v0.41 with zero cutover narrative.

**Suggested shape.**

- **Approach A — Backfill the missing 7 cutover docs.** Each one can be terse (a few paragraphs distilling the relevant CHANGELOG sections, naming the renamed symbols / new dev deps / behaviour changes). Even a minimal stub per version is better than the silent gap. Cluster v0.40+v0.41 into a single `federation-v0.41.0.md` aggregated cutover if the per-release content is too thin.
- **Approach B — Make the CHANGELOG itself the canonical cutover when no separate doc exists.** Drop the "cutover doc per release" promise in STARTHERE.md, replace with: "Per-release migration narratives: see CHANGELOG section headers + `docs/cutover/` for releases with substantial breaking changes." Cheaper, but loses the structured "what to change in your project" focus that the existing cutover docs have over a CHANGELOG.
- **Approach C — Wire CI: every release MUST ship its cutover doc (or an explicit "no cutover needed" marker).** A `tools/check_cutover_present.py` step in the release pipeline: for each version tag, require either `docs/cutover/v<X.Y>.md` OR a `docs/cutover/NO_CUTOVER.txt` entry recording the deliberate skip. Forces the discipline structurally; closes the door on accidental drift.

Recommend **A** (backfill) + **C** (prevent recurrence). B is the resignation option if maintainer bandwidth makes A impossible.

**Decision rationale.** *Pending triage.*

**Retrospective.** *Pending ship.*

---

### `PAPER-83` — `mgf-common catch-up` does not audit `[tool.uv.sources]` paths; rename drift survives the audit

**Status:** ✅ SHIPPED (Approach A) in mgf-common 0.41.3. New `_check_uv_sources_paths` audit row in catch-up. Resolves each `[tool.uv.sources]` entry's `path =` value; surfaces missing paths with a separator-drift suggestion (e.g., `../mgf_common` → `../mgf-common`) when the alternate-separator sibling exists. Read-only — path choice is project-specific; auto-rewrite could fix the surface drift but hide a subtle path-mismatch. 6 new tests in `TestCheckUvSourcesPaths`. Approach B (verify path's pyproject `name` matches) deferred — Approach C (compose with PAPER-79 registry) deferred.

---

### `PAPER-83-original-concern` — (see SHIPPED note above)

| Field | Value |
|---|---|
| Status | ✅ SHIPPED (via parent Approach A) |
| Severity | 🟢 Nice-to-have |
| Submitter | iosRecovery (agent session, 2026-05-26) |
| Submitted | 2026-05-26 |
| Decision | parent SHIPPED `_check_uv_sources_paths` audit row. Approach B (name-vs-path consistency) + Approach C (compose with [[PAPER-79]] registry) deferred. |
| Decided on | 2026-05-27 |
| Scheduled for | mgf-common 0.41.3 (parent) |
| Shipped in | mgf-common 0.41.3 (parent) |

**Concern.** `catch-up`'s drift report covers `pyproject.toml`'s `[project].dependencies` pin, the four-gate `[tool.*]` blocks, `[tool.pytest.ini_options].markers`, and now (per PAPER-73) the test-supervisor dev deps. But it does NOT inspect `[tool.uv.sources]`, which is where consumers point at local editable sibling paths. When a federation sibling is renamed (e.g., `mgf_common` → `mgf-common`, or the upcoming `mgf-ubs` → `mgf-hrb`), every consumer's `[tool.uv.sources]` rows for that sibling silently goes stale — pointing at a path that no longer exists. The next `uv sync` fails with `No such file or directory` (or worse, finds an empty husk and resolves to an empty package). `catch-up` declares the project "clean" while harbouring a latent `uv sync` defect.

**Why it matters.** This is the third surface in iosRecovery alone where the `mgf_common → mgf-common` rename left an unrepaired wound (the first being the editable `.pth` — PAPER-78; the second, briefly, being the empty husk under `PycharmProjects/`). Catch-up has earned the trust position of "audit my project's federation standing" — when it misses a known drift category, the operator learns the audit's silent-on-X behaviour is acceptable. Same defect pattern as the federation marker registration gap closed by PAPER-69 ("if catch-up doesn't audit it, projects diverge"). The fix is small and bounded.

**Concrete evidence.**
- This session, iosRecovery's `pyproject.toml` lines 126-128 (post catch-up `--apply`):
  ```toml
  [tool.uv.sources]
  mgf-common = { path = "../mgf_common", editable = true }
  mgf-http = { path = "../mgf_http", editable = true }
  ```
  Both directories: `../mgf_common` exists but is empty (husk; PAPER-78); `../mgf_http` does not exist at all. Real sources at `../mgf-common` and `../mgf-http`.
- `mgf-common catch-up` output had nothing to say about either; reported the project as having only the (now-fixed) mechanical issues.
- After applying catch-up's mechanical fixes, `uv sync --all-extras` from iosRecovery failed (separate cause — PAPER-84 — but `uv sync` would have failed BEFORE that on path resolution if not for an earlier `pip install -e` that bypassed uv.sources).

**Suggested shape.**

- **Approach A — Audit row for each `[tool.uv.sources]` entry: does the `path` exist?** Simple existence check, suggested fix on miss ("path `../mgf_common` does not exist; nearest sibling is `../mgf-common` — did you mean this?"). `--apply` rewrites when the nearest-sibling match is unambiguous (separator difference only); leaves ambiguous cases for the operator.
- **Approach B — Stronger check: also verify the path resolves to a directory containing a `pyproject.toml` whose `[project].name` matches the source key.** Catches name-vs-path mismatches (e.g., entry says `mgf-common` but path points at `mgf-http` source). Marginal extra value; only worth doing if A's footprint suggests path-mismatches are common.
- **Approach C — Compose with PAPER-79's federation registry.** Once a sibling registry exists, catch-up's uv.sources audit can use the registry's canonical `dist_name` / on-disk layout convention to suggest fixes more confidently.

Recommend **A**; revisit B/C when there's evidence beyond rename-drift cases.

**Decision rationale.** *Pending triage.*

**Retrospective.** *Pending ship.*

---

### `PAPER-84` — `catch-up --apply` bumps the pin into a federation-internal incompatibility; no cross-sibling consistency check

| Field | Value |
|---|---|
| Status | ✅ SHIPPED |
| Severity | 🔴 Blocker |
| Submitter | iosRecovery (agent session, 2026-05-26) |
| Submitted | 2026-05-26 |
| Decision | accept — Approach A + C composed (2026-05-26) |
| Decided on | 2026-05-26 |
| Scheduled for | mgf-common 0.41.2 |
| Shipped in | mgf-common 0.41.2 |

**Concern.** `mgf-common catch-up --apply` bumped iosRecovery's pin from `mgf-common>=0.38,<0.39` to `>=0.41.1,<0.42`. The bump targets a version that mgf-common itself currently ships (0.41.1), but a different federation sibling iosRecovery also depends on — `mgf-http` v0.4.2 — pins `mgf-common>=0.40,<0.41`. The two pins are mutually exclusive. The resulting `pyproject.toml` is locally consistent (each pin individually points at *something* satisfiable) but globally **unresolvable**: `uv sync` immediately fails with "your project's requirements are unsatisfiable." Catch-up reported the project as "Federation contract (DOC-17 / FEDERATION.md): All required artifacts present" and declared 5 fixes successful. The audit thinks the project is healthier than before; in reality it just made `uv sync` impossible.

**Why it matters.** This is the most consequential failure mode catch-up can produce. The promise of `catch-up --apply` is "the mechanical parts are safe." This violates that promise: the mechanical part broke the project's ability to resolve dependencies. Two systemic gaps lie behind it:
1. **No cross-sibling consistency check.** Catch-up bumps mgf-common's pin in isolation, never asking "do this project's OTHER federation pins (mgf-http, mgf-fastapi, mgf-sqlalchemy, etc.) tolerate the new mgf-common version?" The federation's own internal version graph is the thing that should gate the bump.
2. **No federation-wide release-cohort discipline visible from outside.** mgf-common 0.41.1 shipped 2026-05-26 but mgf-http remains on 0.4.2 pinning `mgf-common<0.41`. The federation has internally inconsistent siblings *right now*; any consumer depending on more than one is in the same trap as iosRecovery. There is no published "compatible cohort matrix" telling consumers which (mgf-common, mgf-http, mgf-fastapi, …) combinations resolve.

**Concrete evidence.**
- `mgf-common/pyproject.toml`: `version = "0.41.1"`.
- `mgf-http/pyproject.toml`: `version = "0.4.2"` + `"mgf-common>=0.40,<0.41"`.
- iosRecovery `pyproject.toml` post-catch-up: `"mgf-common>=0.41.1,<0.42"` + `"mgf-http>=0.4,<0.5"`.
- `uv sync --all-extras` output:
  ```
  × No solution found when resolving dependencies for split (markers:
  │ python_full_version >= '3.15'):
  ╰─▶ Because only the following versions of mgf-common are available:
          mgf-common<0.40
          mgf-common>=0.41
      and mgf-http==0.4.2 depends on mgf-common>=0.40,<0.41, we can conclude
      that mgf-http==0.4.2 cannot be used.
  ```
- `pip install -e mgf-common` succeeded because pip is permissive (the resolver warning is non-fatal), so runtime imports still work — but `uv` won't accept the project state. The runtime/install divergence masks the defect from anyone not using `uv`.

**Suggested shape.**

- **Approach A — Cross-sibling consistency check during catch-up.** Before applying a pin rewrite, scan all `mgf-*` entries in `[project.dependencies]` and `[project.optional-dependencies]`, resolve each sibling's `pyproject.toml` (PyPI or `[tool.uv.sources]` local path), and check whether the target mgf-common version satisfies their pins. If not, surface the conflict in the drift report ("bumping mgf-common to 0.41.1 would conflict with mgf-http v0.4.2's pin `<0.41` — defer the bump until mgf-http ships a compatible release") and skip the rewrite on `--apply`. Composes with PAPER-79's registry: the registry knows which siblings exist and which versions are compatible.
- **Approach B — Publish a federation cohort manifest.** A single file (`mgf-common/FEDERATION_COHORTS.yaml` or similar) listing each compatible (mgf-common, mgf-http, mgf-fastapi, mgf-sqlalchemy, ...) tuple, with release dates. Catch-up's pin rewrites target the *latest cohort*, not the latest individual version. Consumer-side, `mgf-common cohort` could print the current cohort + suggest pyproject snippets that match. This is the durable structural fix.
- **Approach C — Block the pin rewrite when ANY other `mgf-*` package is present.** Conservative interim: if catch-up sees `mgf-http`, `mgf-fastapi`, or any other `mgf-*` in dependencies, refuse to bump `mgf-common`'s pin without explicit confirmation and a printed warning ("project has other federation siblings; coordinate the bump across them"). Less precise than A; doesn't require fetching sibling pyprojects.

Recommend **A + B** — A as the immediate catch-up fix (no operator surprise); B as the structural answer that makes federation-level version coherence first-class.

**Cross-cutting.** Composes with PAPER-79 (sibling registry), PAPER-81 (pin-rewrite gating), PAPER-82 (cutover docs). All four are facets of the same underlying issue: federation-wide versioning discipline is not yet load-bearing in the consumer-facing tools.

**Decision rationale.** Accepted Approach A + C composed in mgf-common 0.41.2. Approach B (federation cohort manifest) deferred — bigger structural work, composes with PAPER-79's registry; can land in a v1.0.x release if Approach A+C proves insufficient. Took the dual-approach because A alone has a real failure mode (network-down, sibling-missing-from-PyPI) and C alone has a real false-positive rate (any mgf-* dep blocks the bump). Composed: A first (precise verdict when PyPI reachable), C as the fallback (refuse-if-unverifiable) — both share the same "safe-refuse over surprise-breakage" invariant.

**Retrospective.** Shipped 2026-05-26 in mgf-common 0.41.2.

New module-level functions in `src/mgf/common/cli/_catch_up.py`:
- `_query_pypi_mgf_common_pin(package, version=None)` — stdlib HTTP fetch + JSON parse, extracts the package's `Requires-Dist` line for mgf-common. Returns the constraint string (e.g., `">=0.40,<0.41"`) or None on any failure. 5s timeout.
- `_version_satisfies_constraint(version, constraint)` — stdlib PEP-440-subset version comparator. Splits constraint on commas, parses each clause's comparator + version tuple, compares as zero-padded int tuples. Handles `>=`, `<=`, `<`, `>`, `==`, `!=`. Doesn't handle pre-releases / post-releases / `~=` / wildcards (federation never uses them).
- `_check_cross_sibling_pin_compatibility(target_dir, proposed_version)` — the gate. Scans pyproject for `mgf-*` siblings (other than mgf-common), fetches each one's PyPI Requires-Dist mgf-common pin, runs the version-vs-constraint check. Returns `(safe_to_bump, [conflict_messages])`. PyPI fetch failure → conservative "refuse the bump" Approach C fallback.

`_check_pyproject_pin` now calls the gate at BOTH pin-bump fix-attachment sites (editable + non-editable). On conflict:
- Warn finding still surfaces (drift IS real).
- `fix=` callable is NOT attached (`--apply` won't offer the rewrite).
- Detail message names the conflicting sibling + its specific pin + the proposed target + the workaround ("wait for sibling to publish a release that accepts mgf-common {target}").

The PAPER-65/PAPER-68 `--check-pypi` gate stays as-is — the cross-sibling check is composed alongside, not replacing it. Both gates must pass for a bump to be offered. Closes **PAPER-81** by the same change (the gating is now strictly more conservative than what PAPER-81 asked for).

11 unit tests added (`TestVersionSatisfiesConstraint` × 5 + `TestCheckCrossSiblingPinCompatibility` × 6). Live demo against the iosRecovery scenario shape (mgf-common + mgf-http deps; PyPI's mgf-http 0.4.1 pins `mgf-common<0.38`) — catch-up now correctly REFUSES the bump with a clear message.

**Federation cohort drift (separate work).** Six framework siblings (mgf-http, mgf-fastapi, mgf-django, mgf-sqlalchemy, mgf-alembic, mgf-apiprobe) still pin `mgf-common<0.41` (or earlier) on PyPI. Until those siblings publish bumps that accept `mgf-common>=0.41`, the cross-sibling check will refuse bumps for any consumer that depends on them. This is the correct behavior — the federation is internally inconsistent right now, and catch-up should reflect that rather than silently breaking consumers. The Q-4 backlog now has clear consumer-side urgency: each sibling needs a release that walks its mgf-common pin forward.

---

### `PAPER-86` — PAPER-84 cross-sibling check treats "no runtime pin" as "cannot verify", producing false-positive refusals

| Field | Value |
|---|---|
| Status | ✅ SHIPPED (Approach B) |
| Severity | 🟡 High |
| Submitter | iosRecovery (agent session, 2026-05-26) |
| Submitted | 2026-05-26 |
| Decision | accept Approach B (inspect extras markers); skip C as overkill (2026-05-27) |
| Decided on | 2026-05-27 |
| Scheduled for | mgf-common 0.41.5 |
| Shipped in | mgf-common 0.41.5 |

**Concern.** The PAPER-84 cross-sibling pin-compatibility check (shipped 0.41.2, commit `61cabc9`) correctly refuses bumps when *any* federation sibling pins an incompatible `mgf-common` range. But it conflates two structurally different "no pin found" cases:

1. **Sibling unreachable / package missing / network unreachable** — genuinely *cannot verify*; refusing is conservative + correct.
2. **Sibling declares `mgf-common` ONLY in an extra (e.g., `dev`)**, never as a runtime dep — verifiably *has no runtime constraint*; refusing is over-conservative because a sibling with no runtime mgf-common requirement cannot be broken by ANY mgf-common bump.

The implementation treats both as "cannot verify, refuse." The second case should be treated as "no constraint exists → this sibling does not block the bump." The current behaviour produces refusals against cohorts that are demonstrably safe to walk forward.

**Why it matters.** This converts a Tier-1 safety gate into a Tier-1 *liveness* gate. A federation with one sibling that legitimately doesn't pin mgf-common at runtime (mgf-test-supervisor is the canonical example: it's a test runner; mgf-common is a dev-extra convenience) blocks the bump for every consumer of that sibling — forever, or until the sibling adds an artificial runtime pin just to satisfy the check. The federation already moved siblings forward to repair PAPER-84's cohort skew (mgf-http 0.4.3 now accepts `mgf-common<0.42`); without PAPER-85, the next consumer trying to walk forward hits a stale refusal even after every sibling that *does* pin mgf-common has been updated. The gate's accuracy is the difference between "catch-up routinely guides consumers forward" and "catch-up sits stuck on a known-safe cohort because of a sibling that legitimately doesn't care."

**Concrete evidence.** This session, iosRecovery on 2026-05-26 (after the user shipped mgf-common 0.41.3 + mgf-http 0.4.3):

```
⚠️  pyproject.toml — mgf-common pin
    STALE: declared `>=0.40,<0.41` but current (editable-install) mgf-common is `0.41.3`.
    `--check-pypi` confirms PyPI is at `0.41.3`, BUT a cross-sibling pin compatibility
    check (PAPER-84) blocks the bump:
      - Could not fetch `mgf-test-supervisor`'s mgf-common pin from PyPI (network
        unreachable, package missing, or no pin declared). Refusing pin bump until
        sibling pins can be verified.
```

Direct PyPI query for `mgf-test-supervisor` (latest 0.1.3):
```
requires_dist: [
  'mgf-common; extra == "dev"',          # ← present, but only when [dev] extra installed
  'mypy>=1.10; extra == "dev"',
  'pytest-asyncio>=0.23; extra == "dev"',
  'pytest-timeout<3,>=2.3; extra == "dev"',
  'pytest>=8.0; extra == "dev"',
  'ruff>=0.4; extra == "dev"',
]
```
The package IS on PyPI (HTTP 200, network reachable, package present). The runtime dependency set contains no mgf-common entry — it appears only behind `extra == "dev"`. The PAPER-84 check's `_query_pypi_mgf_common_pin` likely returns `None` for this shape (looking for an unconditional mgf-common requirement) and the caller defaults to the conservative "cannot verify" branch.

For iosRecovery this manifested as: every sibling that DOES pin mgf-common (mgf-http 0.4.3 → `<0.42`) was satisfied; the bump was nevertheless refused because `mgf-test-supervisor` returned no pin. Manual inspection showed mgf-test-supervisor is structurally indifferent to the bump (no runtime mgf-common). The bump is safe; the gate is wrong.

**Suggested shape.**

- **Approach A — Distinguish "no runtime pin" from "unreachable".** `_query_pypi_mgf_common_pin` returns one of three states instead of `Optional[str]`:
  - `Constraint(str)` — sibling has a runtime mgf-common pin; check it.
  - `NoRuntimePin` — sibling reachable on PyPI, has no runtime mgf-common dependency at all; treat as a no-op for this sibling.
  - `Unverifiable` — network failure / 404 / parse failure; preserve current "refuse" behaviour.
  Caller's logic: `Constraint` → check compatibility; `NoRuntimePin` → skip silently; `Unverifiable` → refuse with current message. Three states + matching messages; one new test row each.
- **Approach B — Inspect extras too, treat "only-in-dev-extra" as `NoRuntimePin`.** Same outcome as A but reached by parsing `requires_dist` for the package name across all environment markers and returning `NoRuntimePin` when every occurrence is gated on an extra marker. More precise (catches "mgf-common only in `test` extra," not just `dev`). Slightly more parsing.
- **Approach C — Inspect extras AND optionally honour them.** If the consumer's pyproject installs the sibling's extra (e.g., `mgf-test-supervisor[dev]`), the extra's pin DOES bind and should be checked. If the consumer installs the sibling bare, the extra is unbound. Most accurate; biggest implementation footprint. Probably overkill until evidence shows consumers commonly install sibling extras.

Recommend **A or B** (A as a minimum-viable structural fix; B as the cleaner version that doesn't hardcode the `dev` extra). C is roadmap-level if extras-pinning ever becomes a real pattern.

**Adjacent surface that may want the same treatment.** The release notes for PAPER-84 mention this is "the right answer" for a federation in version-skew; the false-positive class above means *some* refusals are wrong even when the federation is consistent. A second worth-checking case: a sibling that depends on mgf-common only via `provides_extra` (e.g., a meta-package). Worth a single audit pass over the real-world sibling list once A or B lands.

**Decision rationale.** Accepted Approach B (extras-marker inspection) in mgf-common 0.41.5. Took B over A because B doesn't hardcode `dev` — it matches any extras marker (`test`, `optional`, future names). Approach C (consumer-side extras-honouring) deferred as overkill until extras-pinning becomes a real pattern.

**Retrospective.** Shipped 2026-05-27 in mgf-common 0.41.5.

New module-level sentinel `_NO_RUNTIME_PIN` in `src/mgf/common/cli/_catch_up.py`. `_query_pypi_mgf_common_pin` now returns one of three states: `str` (constraint — sibling has unconditional runtime pin), `_NO_RUNTIME_PIN` (reachable + no unconditional dep), `None` (unverifiable). Detection: scan `requires_dist` for `;\s*extra\s*==` marker; treat every extras-gated occurrence as non-binding. ANY unconditional `mgf-common` requirement wins; bare `mgf-common` (no version spec) returns an empty constraint that the version-satisfies check accepts vacuously. Self-reference (package="mgf-common") now also returns `_NO_RUNTIME_PIN` instead of `None` for the same reason (was logically wrong before, but unreachable in practice — self-references never appear in the sibling iteration).

`_check_cross_sibling_pin_compatibility` updated: `_NO_RUNTIME_PIN` → `continue` silently; `None` → keep the conservative refuse with the original "could not fetch" message; `str` → run the satisfies check as before.

10 new tests: 7 in `TestQueryPypiMgfCommonPinTriState` (exercise all four `requires_dist` shapes + network failure + "unconditional wins over extras" + bare-name vacuous constraint), 3 in `TestCrossSiblingCheckHandlesNoRuntimePin` (single sibling skip; mixed no-runtime + compatible; mixed no-runtime + conflicting). Existing 6 `TestCheckCrossSiblingPinCompatibility` tests pass unchanged — legacy `str` and `None` mocks behave identically; the sentinel is opt-in.

The PlasmaMapper / iosRecovery canonical scenario (mgf-test-supervisor extras-only mgf-common dep) now allows the bump through; siblings that legitimately conflict still block. The gate is correct, not just conservative.

---

### `PAPER-87` — `mgf-common migrate` ships no codemod for 0.37→0.41 despite v0.39 carrying a mechanical 12-name rename that bites every consumer using TS-04 `filterwarnings = ["error"]`

| Field | Value |
|---|---|
| Status | ✅ SHIPPED (A in 0.41.5; B in 0.41.6) |
| Severity | 🟡 High |
| Submitter | PlasmaMapper (agent session, 2026-05-26) |
| Submitted | 2026-05-26 |
| Decision | Approach A in 0.41.5 (atomic `(0.38, 0.39)`); Approach B in 0.41.6 (chain composition); Approach C (CHANGELOG-derived) remains deferred |
| Decided on | 2026-05-27 |
| Scheduled for | mgf-common 0.41.5 (A) + 0.41.6 (B) |
| Shipped in | mgf-common 0.41.5 (A) + 0.41.6 (B) |

**Approach B follow-up (v0.41.6).** New `_parse_minor` + `_resolve_migration_chain` helpers in `_migrate.py`. The `migrate()` entry-point now resolves `(from, to)` in two steps: atomic registration first (preserves the v0.41.5 output shape for exact-pair requests), then range composition (collect every registered `(s, t)` where `from_version <= s` AND `t <= to_version`, sort ascending, concatenate rules). Composed requests print a `Composed migration chain:` header listing the resolved hops so consumers see what's about to run. Rules are identifier-anchored + per-rule import-gated, so concatenation is safe — a later hop's pre-rename pattern simply finds nothing in source the earlier hop already renamed.

The PlasmaMapper canonical scenario (`migrate 0.37 0.41` with only a `(0.38, 0.39)` hop registered) now succeeds and surfaces the resolved chain. Unsupported-out-of-range requests still produce the supported-migrations list with a pointer to Approach B explaining why composition didn't help.

6 new tests in `TestPaper87ApproachBChainComposition`. Approach C (auto-derive renames from CHANGELOG) remains roadmap-level — the hand-maintained catalogue is keeping up with the federation's actual rename cadence.

**Concern.** `mgf-common migrate --help` advertises itself as "Apply mechanical renames to migrate consumer source from one mgf-common version to another," but `--dry-run` for **any** modern version pair fails with:

```
✗ unsupported migration: 0.37 → 0.41
Supported migrations:
  0.1 → 0.3
  0.32 → 0.33
```

The catalogue is two pairs deep, neither covering the federation's mechanical-rename event of the last six months: **v0.39's PEP-8 N818 rename** (12 public exception classes gained the `Error` suffix — `HttpBadRequest → HttpBadRequestError`, …, `ProcessTimeout → ProcessTimeoutError`, etc., per CHANGELOG L800-820). The aliases stay importable until v2.0 — but only as `MgfDeprecationWarning`-emitting attribute accesses via `__getattr__`. mgf-common's own TESTING standard (TS-04) tells consumers to set `filterwarnings = ["error"]`, which promotes `MgfDeprecationWarning` to fail-stop. The result: **any consumer following the standard hits a hard collection-time failure across every file that imports any renamed name**, on every pin bump past 0.39. The codemod is exactly the tool that should make this a one-command operation; instead it refuses.

**Why it matters.** This is the gap between mgf-common's "consumers should `catch-up` regularly" promise and the actual cost of catch-up after the federation crosses a rename minor. PlasmaMapper this session: 9 files (4 src + 5 tests), 5 distinct old names, all four pytest collection sessions blocked at import time with 44 errors until the rename was applied. The fix was mechanical — single word-boundary `sed` — but had to be hand-derived from CHANGELOG L802-820, not pulled from the codemod that nominally exists for this purpose. Every other consumer crossing 0.39 will re-derive the same `sed` independently. The cost compounds with each rename minor that lands without a corresponding `migrate` entry: codemod stays at "0.1→0.3, 0.32→0.33" while the actual rename events accrue (0.39 N818, anything in 0.40/0.41/future), and the tool becomes a museum piece consumers stop reaching for. PAPER-82 (cutover docs gap for v0.35..v0.41) is the *narrative* version of this same gap; `migrate` is its mechanical companion that needs the same forward-fill.

**Concrete evidence.** PlasmaMapper this session, after bumping pin 0.37→0.41.4:

```
$ .venv/bin/mgf-common migrate 0.37 0.41 src --dry-run
✗ unsupported migration: 0.37 → 0.41
Supported migrations:
  0.1 → 0.3
  0.32 → 0.33
```

Manual rename across the 9 affected files via `sed -i -E 's/\b(HttpBadRequest|HttpUnauthorized|HttpForbidden|HttpNotFound|HttpConflict|HttpGone|HttpUnprocessable|HttpServiceUnavailable|ProcessTimeout|ProcessNonZeroExit|ProcessSignaled|ServiceNotReady)\b/\1Error/g'` — fully mechanical, no semantic judgement, no manual review needed. Took ten seconds. Should have been `mgf-common migrate 0.38 0.39`.

**Suggested shape.**

- **Approach A — Add the 0.38→0.39 (or "0.38 to current") migration entry covering the N818 rename.** Twelve string-pair renames driven by the same kind of word-boundary regex used in PlasmaMapper's manual fix. One new `_Migration` instance in `_migrations.py` (assuming the existing 0.32→0.33 entry sets the pattern); test fixture with one before/after file per affected name. Smallest possible patch that closes the gap for the most-cited rename event of the last year.
- **Approach B — A.  PLUS make the codemod composable across version ranges.** `mgf-common migrate 0.37 0.41` runs every migration whose source-version is ≥ 0.37 and target-version is ≤ 0.41, in order. Today the codemod is single-pair; a consumer that's 3 minors behind has to invoke it once per pair (and infer the chain from CHANGELOG). Composing the pairs into a chain is what consumers actually want: "walk my source forward from where I am to where the pin now points." The error message "supported migrations: 0.1 → 0.3, 0.32 → 0.33" is itself evidence of the gap — a richer dispatcher would say "no atomic migration for 0.37 → 0.41, but I can compose 0.38→0.39, 0.39→0.40, 0.40→0.41 if you confirm."
- **Approach C — A. PLUS auto-derive renames from CHANGELOG's "Changed" / "Removed" sections.** The N818 rename was already documented in CHANGELOG L802-820 with the full 12-name list; if `migrate` parsed the structured CHANGELOG entries it could keep itself current without a hand-maintained catalogue. Aspirational; tight coupling to CHANGELOG shape. Probably only worth it after evidence that hand-maintenance lags by multiple releases.

Recommend **A as the minimum** (closes the immediate bite); **B as the right structural fix** (matches consumer mental model of "I'm N minors behind, walk me forward"). C is roadmap-level.

**Cost if deferred past v1.0.** Once the v0.x → v1.0 alias-removal cycle fires (slated for v2.0 per CHANGELOG L810-811), consumers crossing the v2.0 boundary without N818-renamed source will hit hard `AttributeError` rather than warnings. A codemod that doesn't cover the rename means every consumer rediscovers the manual `sed` independently — and the ones that miss a usage discover it via production traceback.

**Decision rationale.** Accepted Approach A in mgf-common 0.41.5 — the minimum that closes the immediate PlasmaMapper bite. Approach B (chain composition `migrate 0.37 0.41` walking 0.38→0.39, 0.39→0.40, 0.40→0.41) is the right structural shape and is scheduled for v0.42 once a second consumer needs a chain spanning more than one rename hop. Approach C (auto-derive from CHANGELOG) remains roadmap-level.

**Retrospective.** Shipped 2026-05-27 in mgf-common 0.41.5.

New `_RULES_0_38_TO_0_39` rule tuple in `src/mgf/common/cli/_migrate.py`, generated from the `_N818_RENAMES` source-of-truth tuple. Twelve word-boundary identifier renames, gated on `mgf.common.exceptions` so a consumer's own domain identifiers (e.g., a `ProcessTimeout` class in an unrelated module) stay untouched. Registered under `_MIGRATIONS[("0.38", "0.39")]`.

5 new tests in `TestPaper87V038ToV039Migration`:
- All twelve renames applied in one file (catalog completeness).
- isinstance + subclass + raise rewrites land identically (identifier anchoring works for all five surface shapes).
- Import-gate skips a file with a consumer-domain `ProcessTimeout` that doesn't import from `mgf.common.exceptions`.
- Idempotency: post-rename source byte-identical on re-run (no spurious double-suffixing).
- `--dry-run` doesn't write.

The codemod closes the silent footgun for every consumer crossing the 0.38 → ≥0.39 boundary while running with TS-04 `filterwarnings = ["error"]` — the configuration TESTING.md actively recommends.

---

### `PAPER-88` — `catch-up --apply` adds `[tool.hatch.metadata] allow-direct-references = true` even when the inserted dep uses the plain PyPI form

| Field | Value |
|---|---|
| Status | ✅ SHIPPED (Approach A) |
| Severity | 🟢 Nice-to-have |
| Submitter | PlasmaMapper (agent session, 2026-05-26) |
| Submitted | 2026-05-26 |
| Decision | accept Approach A (guard insertion on `@` in any dev dep); defer B (cleanup pass) (2026-05-27) |
| Decided on | 2026-05-27 |
| Scheduled for | mgf-common 0.41.5 |
| Shipped in | mgf-common 0.41.5 |

**Concern.** Catch-up's own audit output explains the test-supervisor wiring with care:

> mgf-test-supervisor PyPI publish landed at v0.1.2; this is the plain PyPI dep form. **Projects on mgf-common <0.41.1 may carry the legacy `@ git+ssh://...` form + a `[tool.hatch.metadata] allow-direct-references = true` block — both still work; new adopters get the plain form.**

But the `--apply` action contradicts that explanation: PlasmaMapper was a brand-new adopter of the test-supervisor (the dep didn't exist in its tree before this catch-up run), got the plain PyPI form `"mgf-test-supervisor>=0.1.2,<0.2"` as advertised — and *also* got the legacy companion `[tool.hatch.metadata] allow-direct-references = true` block appended to `pyproject.toml`, which serves no purpose without a `@ git+ssh://` dep present. The block is harmless (hatchling ignores it when no direct-reference deps exist), but it's:

1. **Clutter** — pyproject grows a hatchling-specific configuration block that does nothing.
2. **Misleading** — a returning agent (or future audit tool) sees the block and reasons "this project must carry a direct-reference dep somewhere"; the reverse-search turns up nothing, which is its own small footgun.
3. **Inconsistent with the audit's own narrative** — the report says "new adopters get the plain form"; the apply says "you also get the block."

**Why it matters.** Small but multiplied — every new adopter on mgf-common ≥ 0.41.1 hits this. The fix is one condition in the apply branch: skip the `[tool.hatch.metadata]` block when the inserted dep is the plain form. Catch-up's value proposition is "the audit and the apply describe the same project shape" — this is one place where they don't.

**Concrete evidence.** PlasmaMapper this session, after `mgf-common catch-up --apply --check-pypi`. `pyproject.toml` lines 105-106 (dev extras) carry the plain form:

```toml
"mgf-test-supervisor>=0.1.2,<0.2",
"pytest-timeout>=2.3,<3",
```

Lines 382-383 (added by the same `--apply` invocation):

```toml
[tool.hatch.metadata]
allow-direct-references = true
```

No `@ git+ssh://` or `@ file://` dep exists in this file. The block is dead configuration.

**Suggested shape.**

- **Approach A — Guard the `[tool.hatch.metadata]` insertion.** Add it only when the inserted dep string carries `@` (direct-reference marker). Mechanical; one branch. If a project already has the block from an earlier catch-up that legitimately needed it, leave it alone (presence is idempotent; removal needs operator judgement).
- **Approach B — Also offer to *remove* the block on subsequent `--apply` runs when no direct-reference deps remain.** Catch-up audits the existence of `allow-direct-references = true` AND the dep set; offers to remove the block on `--apply` if no `@`-form dep is found. More work; closes the cleanup half of the loop for projects upgraded from the legacy form.

Recommend **A** for the immediate fix; B only if evidence accumulates that projects are stranded with dead blocks from earlier sessions.

**Decision rationale.** Accepted Approach A in mgf-common 0.41.5. The guard is one condition (`any("@" in dep for dep in combined_dev_deps)`). Approach B (offer to remove the dead block on subsequent `--apply` runs) deferred — projects upgraded from the legacy form likely still want the block; a removal pass needs explicit operator judgement and isn't worth the audit-row footprint without evidence.

**Retrospective.** Shipped 2026-05-27 in mgf-common 0.41.5.

`_apply_supervisor_wiring` in `src/mgf/common/cli/_catch_up.py` now computes `combined_dev_deps = [str(d) for d in dev_extras] + deps_to_add` and inserts the `[tool.hatch.metadata]` block only when at least one entry contains `@`. The guard considers BOTH pre-existing dev extras and the deps being inserted — a project with a legacy `mgf-test-supervisor @ git+ssh://...` dep that's missing the hatch block (e.g., adopted catch-up across an mgf-common version split) still gets the block; a project with only plain-form deps doesn't.

Existing `test_full_wiring_from_minimal_hatchling` rewritten + renamed to `test_wiring_from_minimal_hatchling_skips_hatch_block` (the post-PAPER-88 contract). New `test_hatch_block_added_when_direct_ref_dep_present` exercises the positive case (project carries `some-pkg @ git+ssh://...` → block lands). Other 6 supervisor-wiring tests unchanged.

The PlasmaMapper-shaped audit (no `@`-form dep anywhere in dev extras) now produces a clean pyproject. Catch-up's narrative ("new adopters get the plain form") matches its action.

---

### `PAPER-89` — `mgf-test-supervisor` has no ingestion path for non-Python test runners; every federation consumer with a frontend re-builds the polyglot bridge

| Field | Value |
|---|---|
| Status | ⏸ DEFERRED — cross-sibling polyglot wave |
| Severity | 🟡 High |
| Submitter | PlasmaMapper (agent session, 2026-05-27) |
| Submitted | 2026-05-27 |
| Decision | accept Approach A (`--merge <jsonl>` flag in mgf-test-supervisor); execute in a separate session — touches the supervisor sibling, not mgf-common, and pairs with PAPER-90 (TESTING.md polyglot doc) + PAPER-92 (SDK contract worked example). Sized as a single cross-sibling wave at next maintainer touch of mgf-test-supervisor. |
| Decided on | 2026-05-27 |
| Scheduled for | next mgf-test-supervisor minor (paired with PAPER-90 + PAPER-92) |
| Shipped in | — |

**Concern.** `mgf-test-supervisor` advertises itself as the federation's canonical test runner (TS-22..TS-27); its outcome contract — `mgf.common.testing.TestRecord` schema 1.0.0, 16 stable fields — is documented as a consumer-readable JSONL contract. But the supervisor's *driving* path is hardcoded to pytest as a subprocess. It has no `--input <jsonl>` / `--external-runner` / `--merge <path>` flag. The result: every federation consumer with a non-Python test surface (SvelteKit Vitest, Playwright, Cargo test, Go test, …) faces the same fork:

  - **Option A** — keep the JS/other tier outside the supervisor; settle for two parallel status surfaces, no unified roll-up, no shared budget knapsack, no shared quarantine ledger.
  - **Option B** — write a per-consumer polyglot bridge that runs the JS runner, converts the output to TestRecord-shaped JSONL, and merges with the supervisor's `.gates/last-run/run.jsonl`.

The Magogi federation is multi-language by design (DOMAIN-09 lists SvelteKit + FastAPI + arq + Postgres). Every consumer with a frontend will hit this. PlasmaMapper just shipped Option B (`tools/test_all.py`, see "Concrete evidence" below); it's ~340 LOC, schema-pinned by tests, but it's a per-consumer rebuild.

**Why it matters.** The supervisor's documented value proposition is the **unified outcome surface** — one JSONL, one budget knapsack, one quarantine ledger, one summary, one CI gate. Forcing each consumer to rebuild the polyglot half loses that value for any project with a frontend. Today PlasmaMapper's `make test-all-tier0` produces the right unified shape, but a sibling project's bridge will diverge in subtle ways (different test_id format, different tier mapping, different summary template) and the "federation runs one shape of test report" guarantee silently degrades to "each consumer runs its own."

The fix is **bounded**: the supervisor already knows how to read TestRecord JSONL (the schema is alignment-pinned at the mgf-common side). An ingestion flag is additive — no behaviour change to the existing pytest-driven path.

**Concrete evidence.** PlasmaMapper, 2026-05-27 (commits `8ade7eb`, `bf629a2`, `70b404c`):

- New `tools/test_all.py` (340 LOC, stdlib-only Python) is a polyglot orchestrator that:
  - Runs `mgf-test-supervisor --tier 0-N` (subprocess); reads its `.gates/last-run/run.jsonl`.
  - Runs `pnpm -C apps/web exec vitest run --reporter=json` + `pnpm exec playwright test --reporter=json`; converts each runner's JSON output to TestRecord-shaped JSONL.
  - Merges into `.gates/last-run-merged/run.jsonl`.
  - Exit code is the OR of all subprocesses.
- Schema alignment with `mgf.common.testing.TestRecord` is pinned by 14 unit tests at `tests/unit/tools/test_test_all_orchestrator.py`. Drift on either side fails the test.
- JS-side tag taxonomy (`@smoke`, `@contract`, `@use_case`, …) mirrors the Python federation markers at `apps/web/tests/MARKERS.md`. Tag-to-tier mapping matches `mgf.common.testing.MARKER_TIER` byte-for-byte (also pinned).

Running `make test-all-tier0` produces 159 merged TestRecord JSONL lines across both surfaces, all parsing via `TestRecord.from_jsonl_line(...)`. The model works — but it's PlasmaMapper-specific scaffolding for what should be a federation primitive.

**Suggested shape.**

- **Approach A — `--merge <path>` flag on `mgf-test-supervisor`.** The supervisor reads pre-existing TestRecord JSONL at `<path>` and unions it into `.gates/last-run/run.jsonl` + the summary, without driving pytest. Composes cleanly with the existing pytest path: `mgf-test-supervisor --tier 0 && mgf-test-supervisor --merge web-run.jsonl` produces one merged report. Adds: one CLI flag, one schema-validating loader (the JSON parse already exists for the resume path), one merge step in the summary writer. Approach A is the minimum-viable structural fix.

- **Approach B — `mgf.testing.external` consumer helper.** A documented Python helper (alongside the existing `TestRecord` dataclass) that wraps the polyglot bridge pattern: `from mgf.testing.external import run_polyglot; run_polyglot(["mgf-test-supervisor --tier 0", "pnpm -C apps/web exec vitest run"])`. The helper handles JSONL collection + merge + exit-code OR. Consumers like PlasmaMapper drop their bespoke `tools/test_all.py` in favour of the canonical helper. Approach B is the "right" shape — but adds a Python dep where the supervisor today is stdlib-only.

- **Approach C — both A and B.** A is the supervisor side (read external JSONL); B is the consumer side (write external JSONL from arbitrary test runners). Together they give a clean lego-block model: any test runner that emits TestRecord JSONL becomes federation-aware via `--merge`.

Recommend **A** as the minimum that unblocks every consumer; **B** as a follow-up that retires the per-consumer scaffolding. C is the full shape.

**Cost if deferred past v1.0.** Every new federation consumer with a frontend re-implements the bridge from scratch. PlasmaMapper's `tools/test_all.py` is candidate template code, but per-consumer rebuilds drift faster than per-consumer adoptions. By v1.0 there will be N consumer-specific polyglot bridges, each with a slightly different test_id format, slightly different summary shape, slightly different tier-mapping interpretation. Closing the gap then is breaking-change-shaped; closing it now is additive.

**Decision rationale.** *Pending triage.*

**Retrospective.** *Pending ship.*

---

### `PAPER-90` — `docs/standards/TESTING.md` does not address frontend / JS test orchestration; the 13-marker taxonomy is Python-centric

| Field | Value |
|---|---|
| Status | ⏸ DEFERRED — cross-sibling polyglot wave |
| Severity | 🟡 High |
| Submitter | PlasmaMapper (agent session, 2026-05-27) |
| Submitted | 2026-05-27 |
| Decision | accept Approach A (TESTING.md §16 polyglot section); pair with PAPER-89 (supervisor `--merge` flag) + PAPER-92 (SDK contract worked example) in the next polyglot wave. The TESTING.md addition is a docs change in mgf-common; ships in the same session as the supervisor change so the standard names the mechanism the supervisor implements. |
| Decided on | 2026-05-27 |
| Scheduled for | paired with PAPER-89 + PAPER-92 |
| Shipped in | — |

**Concern.** `mgf-common standards testing` defines the canonical 13-marker taxonomy (smoke / contract / property / regression / fuzz / conformance / use_case / concurrency / perf / stress / e2e + integration + slow). The standards doc mentions the markers exclusively in the pytest context — `pyproject.toml [tool.pytest.ini_options].markers` is the worked example. There is no guidance for:

  - How a frontend (Vitest / Playwright / Jest / Cargo / Cypress / WebdriverIO) declares its tags
  - How those tags map to federation tiers
  - Whether tags-in-titles (`test('foo @smoke', ...)`) is the federation convention or per-consumer choice
  - How the orchestrator (per PAPER-89) discovers + merges non-Python results

PAPER-89 covers the orchestration mechanism; PAPER-90 covers the **discipline** that names the orchestration. Every web-shaped federation consumer needs a story for "what's the equivalent of `pyproject.toml markers` on the JS side?"

**Why it matters.** Without a canonical pattern, each consumer invents one. PlasmaMapper picked tag-in-test-title (`@smoke`, `@contract` tokens in the Playwright test name); a sibling could equally use `describe.skip(...)` blocks, a `.smoke.test.ts` file-naming convention, a Vitest project-name partition, or a custom reporter plugin. All work; only one is the federation pattern. The standard naming the pattern keeps consumers from drifting.

**Concrete evidence.** PlasmaMapper, 2026-05-27. The decision to use `@<tag>` tokens in test titles was made locally (commit `8ade7eb`); the rationale lives in `apps/web/tests/MARKERS.md` (78 lines). The choice was driven by:

  - Vitest 4: `vitest run -t "@smoke"` runs tests whose name matches; built-in, no plugin needed.
  - Playwright: `playwright test --grep "@smoke"` does the same; built-in.
  - The tag set is the test runner's selection knob AND the JSONL `markers[]` field — two birds, one literal.

This decision is reasonable but it's *invented* locally. The next federation consumer will invent something different, and the merged JSONL across consumers won't tier-classify identically. The standards doc could close that by canonicalising the convention.

**Suggested shape.**

- **Approach A — New `§16 Polyglot test surfaces` section in `mgf-common/docs/standards/TESTING.md`.** Covers:
  - The TestRecord JSONL contract as the cross-language standard.
  - The marker-tag convention for Vitest / Playwright (tag-in-title, `@`-prefixed).
  - Tag-to-tier mapping mirrors the Python `MARKER_TIER` exactly; a sibling can extend with new markers but cannot rename existing ones.
  - The polyglot orchestrator pattern (subprocess each runner, emit TestRecord JSONL, merge).
  - When `mgf-test-supervisor --merge` (PAPER-89) lands, it becomes the canonical merge target.
- **Approach B — A. PLUS a reference reporter for Vitest + Playwright** at `mgf-common/templates/test-reporters/`. Two ~80-LOC files (one per framework) that emit TestRecord JSONL directly. Consumers `cp` them instead of writing their own converter (the Python conversion path in PlasmaMapper's `tools/test_all.py` is ~120 LOC; a direct reporter would be smaller).
- **Approach C — A. PLUS a marker-discipline rule (TS-28?)** that consumers MUST mirror the 13-marker taxonomy in their JS test surface. Names the discipline so `catch-up` could audit it: e.g., "if `apps/web/tests/*.spec.ts` exists, MUST have a `MARKERS.md` referencing every federation tag."

Recommend **A** for the immediate fix (one section in TESTING.md); **B** + **C** as follow-ups when there's enough consumer evidence to template + audit.

**PlasmaMapper's `apps/web/tests/MARKERS.md` is candidate template content** for Approach B's documentation.

**Cost if deferred past v1.0.** Cross-consumer JSONL roll-ups become genuinely hard to interpret. A Magogi-foundation-wide CI dashboard ("is the federation green?") that sums across project reports degrades into "is every project's local interpretation of the markers green?" The marker discipline is the contract that makes the cross-consumer rollup meaningful.

**Decision rationale.** *Pending triage.*

**Retrospective.** *Pending ship.*

---

### `PAPER-91` — `use_case` marker carries no `persona:` field; persona-grouped roll-ups string-match `markers[]`

| Field | Value |
|---|---|
| Status | ⏸ DEFERRED |
| Severity | 🟢 Nice-to-have |
| Submitter | PlasmaMapper (agent session, 2026-05-27) |
| Submitted | 2026-05-27 |
| Decision | DEFERRED (2026-05-27) — defer until 2nd consumer hits the string-match-on-marker footgun. The federation L2-floor walk-up didn't surface any consumer using persona-grouped roll-ups other than PlasmaMapper. Pre-v1.0 the cost of NOT adding `actor` is one local convention; adding it post-v1.0 is breaking-change-shaped per the PAPER. Pre-v1.0 we keep the option open. Re-open if (a) a 2nd consumer files the same shape, or (b) the schema-1.x bump is scheduled. |
| Decided on | 2026-05-27 |
| Scheduled for | — |
| Shipped in | — |

**Concern.** The federation `use_case` marker (`mgf-common standards testing` §13.11) is documented as "reproduces a consumer scenario from FEEDBACK paper or recipe doc". For consumer apps with multiple user personas, the natural shape of a use-case test is "Faraj's operator shift", "Mahdi's pickup-Karbala day", "Hala's morning verdict" — each scenario *mimics* a specific persona. Dashboards / roll-ups want to group by persona ("operator surface health: 7 of 8 scenarios green; driver: 5 of 5 green; manager: 4 of 4 green"). Today the persona identifier lives in `markers[]` as a string tag (PlasmaMapper convention: `@use_case @operator`); grouping requires string-matching `markers` for an `@operator|@manager|@driver` prefix, which is fragile.

A first-class `persona:` field (or more generally, an `actor:` field) on TestRecord would make persona-grouped roll-ups trivial.

**Why it matters.** Persona-grouped roll-ups become the natural cross-project conformance dashboard. PlasmaMapper has three personas; another consumer might have "supplier" + "courier" + "warehouse-manager"; another might have "researcher" + "reviewer" + "admin". The persona axis is the single most-asked roll-up dimension for use-case suites — every consumer with a multi-persona product faces this. The current workaround (string-tag in `markers[]`) works but loses type safety: a typo in `@operatr` silently un-groups the test.

**Concrete evidence.** PlasmaMapper, 2026-05-27 (commit pending Wave 4). The four use-case scenarios at `apps/web/tests/use_case/`:

```ts
test('07:00 → 16:00 — full shift end-to-end @use_case @operator', ...)
test('06:15 → 15:00 — pickup-Karbala-via-reroute day @use_case @driver', ...)
test('07:30 → 19:00 — morning verdict to evening emergency @use_case @manager', ...)
```

The orchestrator parses tags and emits `markers: ["use_case", "operator"]`. A roll-up "show me every operator-mimicking scenario across the federation" greps `markers contains "operator"`, which is fragile (false positives on a non-persona marker called "operator" in some other consumer).

**Suggested shape.**

- **Approach A — Add `persona: str | None` to TestRecord schema 1.1.** Promotes the persona identifier out of `markers[]` into a typed field. Consumer-side converters (PlasmaMapper's `tools/test_all.py`) recognise `@operator` / `@manager` / `@driver` tags and write them to `persona`; other tags stay in `markers[]`. The schema-version bump from 1.0.0 → 1.1.0 is additive-only (consumer-side TestRecord parsers ignore unknown fields by Python dataclass convention).
- **Approach B — Generalise to `actor: str | None`.** Same shape; broader name. "Persona" is consumer-app vocabulary; "actor" works for libraries too (e.g., a use-case test could mimic a "caller library" or "downstream service" rather than a human persona).
- **Approach C — Don't add a field; document the convention.** TestRecord stays at 1.0.0; the standards doc names `@<persona>` tags as the convention. Consumers do their own grouping. Cheaper but loses the type-safety win.

Recommend **B** (`actor` is the more federation-shaped name). The field is `Optional[str]` so existing test surfaces don't have to populate it; consumers that DO populate it get the typed roll-up.

**Cost if deferred past v1.0.** The string-tag-in-markers convention calcifies. A v2.0 retrofit to a typed field would be breaking-change-shaped (consumers parsing `markers[]` for the persona break when the persona moves to `actor`). Doing it now is additive.

**Decision rationale.** *Pending triage.*

**Retrospective.** *Pending ship.*

---

### `PAPER-92` — `mgf-common standards testing` lacks a worked example of an SDK contract test (FastAPI ↔ generated TS client) — every web-shaped consumer faces it

| Field | Value |
|---|---|
| Status | ⏸ DEFERRED — cross-sibling polyglot wave |
| Severity | 🟢 Nice-to-have |
| Submitter | PlasmaMapper (agent session, 2026-05-27) |
| Submitted | 2026-05-27 |
| Decision | accept Approach A (TESTING.md §contract worked example); pair with PAPER-89 + PAPER-90 in the next polyglot wave. Same docs change shape; ships in the same session for documentation coherence. |
| Decided on | 2026-05-27 |
| Scheduled for | paired with PAPER-89 + PAPER-90 |
| Shipped in | — |

**Concern.** `mgf-common standards testing` §"`contract` marker" defines the marker as "wire-format + SDK-surface pins" but the worked examples are mgf-common-internal (the `TestRecord` schema alignment). For a typical federation consumer the SDK contract is **FastAPI ↔ generated TypeScript client**: the FastAPI app emits OpenAPI via `app.openapi()`; `openapi-typescript` consumes that to generate a TS types client; the TS client must stay in sync with the FastAPI surface or browser code silently breaks on a route rename / response-shape change. Every web-shaped consumer of the federation faces this. The standard doesn't have an example for it; consumers reinvent the pattern.

**Why it matters.** The CI flow that detects SDK drift is well-known but per-consumer-bespoke: dump OpenAPI to a tmpfile → run `openapi-typescript` → git diff against the committed `_generated.ts` → fail CI on drift. PlasmaMapper shipped this at commit `849d867` (the original implementation); commit `70b404c` (Wave 3) added a Python-side conformance pin at `tests/contract/test_openapi_schema_pin.py` (pins paths + operationIds + response codes against a snapshot fixture). The pattern is now battle-tested across two consumer surfaces — a candidate template for the standards doc.

**Suggested shape.**

- **Approach A — Add a §"Worked example: SDK contract test (FastAPI ↔ TS)" subsection** to `mgf-common/docs/standards/TESTING.md` under the existing `contract` marker discussion. Cover:
  - The two-sided shape: Python pins the OpenAPI emission; JS pins the regenerated TS client matches the committed file.
  - The minimal moving parts: a snapshot fixture, a regen script, a CI step that diffs.
  - The escape hatch: a documented regen recipe (`make sdk-regenerate`) so intentional API changes commit cleanly.
- **Approach B — A. PLUS a reference template at `mgf-common/templates/contract-tests/sdk-openapi/`.** Two files: a `test_openapi_schema_pin.py` template + a `regen-openapi-client.sh` template. Consumers `cp` them and adjust the path constants. Mirrors the existing `mgf-common/templates/` directory layout.

Recommend **A** for the standards-doc fix; **B** as a follow-up when the template directory grows enough to be useful.

**PlasmaMapper's `tests/contract/test_openapi_schema_pin.py` + `apps/web/scripts/regen-openapi-client.sh` + `make sdk-check` are candidate template content** for Approach B.

**Decision rationale.** *Pending triage.*

**Retrospective.** *Pending ship.*

---

### `PAPER-93` — `mgf-common standards web/` has no "SSE through a dev/prod proxy" recipe; every push-surface consumer rediscovers the buffering + Playwright gotchas

| Field | Value |
|---|---|
| Status | ✅ SHIPPED |
| Severity | 🟡 High |
| Submitter | PlasmaMapper (agent session, 2026-05-27) |
| Submitted | 2026-05-27 |
| Decision | accept the suggested shape — new `docs/recipes/web-sse.md` + cross-links from the web-standards recipe index and the recipes A–Z index. |
| Decided on | 2026-05-27 |
| Shipped in | v0.41.7 (commit [`d5b5087`](https://codeberg.org/magogi-admin/mgf-common/commit/d5b5087)) |

**Concern.** A FastAPI `text/event-stream` endpoint behind a SvelteKit proxy (dev `vite`, prod `+server.ts` `[...path]` forward, or any nginx/ALB) silently **buffers** the response body, so live push violates a low-latency budget even though the API side is correct. The federation's web-shaped guidance (`mgf-common standards web/` recipes) documents the FastAPI side but not the *proxy + browser-test* side, so every consumer rediscovers the same fix.

**Why it matters.** PlasmaMapper's Phase-4 dispatch push had a documented ≤2 s budget (ADR-013) that failed end-to-end through the dev proxy; the fix (commit `e292199`, re-design Wave 2) took most of a session and the root cause was four layered problems, none in the API code:
1. The proxy SSE-response branch must set `Cache-Control: no-cache, no-transform`, `X-Accel-Buffering: no`, and `Content-Encoding: identity` (defensive against gzip-rewrap proxies).
2. Node `fetch` forwarding needs `duplex: 'half'` for a streamed body.
3. Playwright `waitForLoadState('networkidle')` **never fires** with an open `EventSource` (the long-lived connection counts as in-flight) — tests must wait on a concrete readiness signal instead.
4. Playwright spawns `webServer` *before* `globalSetup`, so any env the proxy needs (API base URL, per-role tokens) must be declared statically in `webServer.env`, not threaded from `globalSetup`.

**Suggested shape.** Add a §"SSE / live-push through a proxy" recipe to the web standards (or a `docs/recipes/web-sse.md`): the three response headers, the `duplex: half` note, and the two Playwright gotchas (readiness-signal-not-networkidle; webServer-before-globalSetup ordering). PlasmaMapper's `apps/web/src/routes/api/[...path]/+server.ts` SSE branch + `playwright.config.ts` are candidate reference content.

**Cost if deferred past v1.0.** Every web-shaped federation consumer with a live-push surface (SSE or WS) re-burns a debugging session on the identical, non-obvious failure mode.

**Decision rationale.** Accepted — pure-prose docs fix, no API risk, high consumer value (the failure mode costs a full debugging session on first encounter and recurs per web-shaped consumer). Shipped as a standalone recipe (`docs/recipes/web-sse.md`) rather than inlined into the web-standards README so it is directly linkable and `cp`-able; the README's recipe index carries the cross-link. Stayed at the recipe layer (not a new MUST/standard rule) — it is a "here's how the rules land for a streaming surface" narrative, matching the rest of the web subtree's shape; a TS-style discipline rule can follow if a second consumer hits the same failure.

**Retrospective.** Shipped in v0.41.7 (recipe content in commit `d5b5087`; recipe-index propagation + version cut in the release commit). Recipe added at `docs/recipes/web-sse.md` capturing all four root causes from PlasmaMapper's commit `e292199` (re-design Wave 2): the three SSE-only proxy response headers (`Cache-Control: no-cache, no-transform`, `Content-Encoding: identity`, `X-Accel-Buffering: no`) + identity-`TransformStream` force-flush, the forwarding-`fetch` `duplex: 'half'` requirement, and the two Playwright gotchas (`networkidle` never fires with an open `EventSource`; `webServer` boots before `globalSetup` so proxy env must be static in `webServer.env`). Cross-linked from `docs/standards/web/README.md` and indexed in `docs/recipes/README.md` (both the by-domain and A–Z tables).

---

### <a id="PAPER-94"></a>`PAPER-94` — MOVED to `mgf-sqlalchemy`

| Field | Value |
|---|---|
| Status | MOVED |
| Moved on | 2026-05-27 |
| Moved to | [mgf-sqlalchemy/FEEDBACK.md §PAPER-01](https://codeberg.org/magogi-admin/mgf-sqlalchemy/src/branch/main/FEEDBACK.md#paper-01) |
| Why | Sibling-scoped feedback per DOC-13 — `mgf-sqlalchemy`'s public surface, so the paper lives in that sibling's own FEEDBACK.md. |

Original title: *`mgf-sqlalchemy` could host the RLS-scoped pagination pattern (`tenant_session` + `paginate_cursor`) every multi-tenant consumer copy-pastes*

The full original entry — concern, evidence, suggested
shape, decision rationale, retrospective — is preserved
at the new location. This stub stays here so the
federation-wide paper number remains searchable.

Outcome (recorded at the sibling): **accepted** —
`tenant_session` already shipped at mgf-sqlalchemy v0.1; the
missing `paginate_cursor` pure helper was added. The
`TenantQueryService` L2 wrapper was deliberately not ported
(framework-DI shape, not framework-agnostic library shape).

---

### <a id="PAPER-95"></a>`PAPER-95` — `mgf-test-supervisor`: a tier selection that matches **zero** tests reports a passing run (silent adoption cliff), plus a cluster of safety/UX hardening items

| Field | Value |
|---|---|
| Status | OPEN |
| Severity | 🟡 High |
| Submitter | PlasmaMapper federation test-hardening pass (agent session, 2026-05-28) |
| Submitted | 2026-05-28 |
| Decision | pending |
| Decided on | — |
| Scheduled for | — |
| Shipped in | — |

**Concern.** `mtest --tier N` selects tests whose computed tier is in the
requested set (`selection.py` `select_tests`, filtering on
`effective_tier`); unmarked tests fall to `DEFAULT_TIER = 1` (`tiers.py`).
A project that has adopted the supervisor but has **no `smoke`-marked
test** will see `mtest --tier 0` select **0 tests** and exit **`OK`**.
A CI gate wired to `mtest --tier 0` (the "build-broken" fast gate) is then
permanently green while running nothing. The same silent-pass happens for
any tier with no members. This is the adoption cliff: the tool is wired,
the gate is green, and it is testing nothing — with no signal that says so.

**Why it matters.** This is exactly the state most of the federation is in
right now. A breadth audit across the 8 `mgf-*` siblings + PlasmaMapper
found tier-0 (`smoke`) **empty or near-empty in almost every repo**: the
4 freshly-extracted libs (`mgf-livepush`, `mgf-tele-core`,
`mgf-tele-teltonika`, `mgf-auth`) had **no** `smoke` marker before this
pass, and `mgf-common`'s ~1,557 unmarked tests leave its tier-0 effectively
empty. Any of those repos running `mtest --tier 0` in CI would report a
passing build-broken gate having executed nothing. The failure mode is
invisible precisely because the exit code is 0.

**Concrete evidence.**
- `src/mgf/test_supervisor/selection.py` — `select_tests` returns a
  `SelectionReport` whose `selected` list can be empty; the supervisor
  prints the count but exits 0 regardless (verified: `mtest --tier 0` in
  `mgf-livepush` *before* this pass added a smoke set).
- `src/mgf/test_supervisor/tiers.py:48` — `DEFAULT_TIER = 1` means *only*
  `smoke`-marked tests are tier-0, so tier-0 is empty until a project
  deliberately marks smoke tests.
- Observed during this pass: `mtest --tier 0` on `mgf-fastapi` /
  `mgf-sqlalchemy` selects the single `tests/smoke/test_mtest_self_check.py`
  and takes **~32s** — see "self-check-as-smoke" below.

**Suggested shape.**
- **Primary — fail loud on an empty selection.** When `select_tests`
  returns zero selected tests, print a prominent warning and exit
  **non-zero by default** (or gate behind a default-on
  `--require-nonempty` / opt-out `--allow-empty`). "I ran nothing" must
  never look like "everything passed."
- **`mtest --audit`** (new): report, without running anything, the
  per-tier test counts, the % of tests that are unmarked (→ tier-1 by
  default), and TS-04 (`filterwarnings=["error"]`) / TS-06 (coverage
  gate) presence in the project's `pyproject.toml`. This is the
  one-command "is my supervisor adoption actually doing something?"
  health check — it would have surfaced every gap this pass found.
- **`mtest init`** (new): scaffold the `[tool.pytest.ini_options]` block
  (timeout / markers / filterwarnings) + `.gates/` gitignore line. Every
  adopting repo currently hand-copies this; a scaffolder removes the
  copy-paste drift (the 4 new libs each needed the identical block).

**Related smaller items (same tool, file together):**
1. 🟡 **Self-check-as-smoke makes tier-0 slow.** The federation pattern of
   marking `tests/smoke/test_mtest_self_check.py` as the *only* `smoke`
   test makes `mtest --tier 0` take ~30s (it spawns a full pytest
   subprocess), which contradicts the tier-0 "<1s build-broken signal"
   intent in `tiers.py` (`TierBudget("tier-0", 0.10, 1.0, …)`). Recommend
   the recipe move the self-check to its own `conformance`/`use_case`
   (tier-2) slot and seed tier-0 with genuinely sub-second import/construct
   smoke tests (what this pass added to the 4 new libs).
2. 🟡 **Crash detection is mocked, not lived.** `tests/.../test_self_check_visibility.py`
   asserts on a *mocked* JSONL rather than spawning a real pytest that
   `exit(1)`s / segfaults / hangs / has a config error. The tool's entire
   value proposition (crash isolation, hang detection) is therefore not
   exercised end-to-end. Recommend live subprocess crash tests.
3. 🟡 **No `.gates/` lock for concurrent runs.** `quarantine.py` /
   `records.py` / the duration store append to `.gates/` with no file
   lock; two concurrent `mtest` invocations (parallel CI shards on a
   shared checkout, or a dev run during CI) can interleave writes and
   corrupt state. Recommend an advisory `flock` on `.gates/`.
4. 🟢 **No coverage gate on the tool itself.** `mgf-test-supervisor`'s own
   `pyproject.toml` sets no `fail_under`; a surveillance tool should hold
   itself to TS-06.
5. 🟢 **JSONL read-all-into-memory.** `records.py` loads the whole
   `run.jsonl` into memory; streaming-parse it for large (1k+ test) runs.
6. 🟢 **Subprocess env not scrubbed.** `self_check.py` passes `env.copy()`
   to the pytest subprocess without dropping obvious secret-bearing vars
   (`*_SECRET`, `*_TOKEN`, `AWS_*`) — defense-in-depth for CI logs.

**Note on scoping.** Per DOC-13 this is partly sibling-scoped (the tool's
own code) and partly federation-wide (the adoption cliff + tier-0 intent
touch TS-22..27 and every consumer's CI wiring). Filed here at the
federation centre per the cornerstone's role; items 1–6 are also being
implemented directly in the `mgf-test-supervisor` repo in the same
hardening pass that filed this paper.

---

### <a id="PAPER-96"></a>`PAPER-96` — `mgf-test-supervisor` silently quarantines **class-based tests** (JUnit reconciliation bug → false green) — FOUND + FIXED

| Field | Value |
|---|---|
| Status | ✅ SHIPPED (fix on mgf-test-supervisor `main`; release pending) |
| Severity | 🔴 Blocker |
| Submitter | PlasmaMapper federation test-hardening pass (agent session, 2026-05-28) |
| Submitted | 2026-05-28 |
| Decision | fixed at source — keying reconciliation by `(classname, name)` |
| Decided on | 2026-05-28 |
| Shipped in | mgf-test-supervisor commit `1fd845c` (on `main`; not yet version-bumped/published) |

**Concern.** The supervisor reconciles each chunk's pytest JUnit XML back
to the collected node-ids by reconstructing a node-id string from the
JUnit `<testcase>` attributes. But pytest's JUnit output **omits the
`file` attribute by default** and reports class-based tests with a dotted
`classname` (`tests.test_x.TestY`) + a method `name`. The reconstruction
fallback did `classname.replace('.', '/') + '.py::' + name`, turning
`tests.test_x.TestY` into `tests/test_x/TestY.py::method` — which is
correct for *module-level* tests by accident, but **wrong for every
class-based test**. Those then never matched their collected node-id, were
flagged `MISSING_FROM_XML`, recorded as `error`, and after repeats
**auto-quarantined** — i.e. dropped from future runs.

**Why it matters.** 🔴 This is the worst failure mode for a test tool: a
**passing** class-based suite reported as not-run, then silently excluded.
`mgf-auth`'s entire 54-test suite (all class-based: `TestVerifySession`,
`TestAlgNone`, …) was being quarantined wholesale while `pytest` passed
100%. A CI gate on `mtest` would go green having run none of them. It is
invisible because the chunk exits 0 and the quarantine is "helpful." Any
federation repo with `class Test…:` tests is affected; only function-only
suites (e.g. the smoke files) escaped, which is why W0 didn't surface it.

**Concrete evidence.** `supervisor.py::_parse_junit` fallback branch (pre-fix);
observed JUnit `<testcase classname="tests.test_clerk_session.TestVerifySession"
name="test_empty_token_raises" time="…"/>` with **no `file` attr**;
`.gates/quarantine.json` in mgf-auth held ~43 entries with
`last_failure_summary: "chunk exited with 0"` + `code: MISSING_FROM_XML`.

**Fix (shipped at source).** Key reconciliation by the canonical
`(classname, name)` pair pytest emits — on both sides — instead of
reconstructing a path. New `_node_id_to_classkey(node_id)` inverts the
pytest node-id (`tests/test_x.py::TestY::test_z` → `("tests.test_x.TestY",
"test_z")`, parametrised `[param]` stays in `name`, nested classes join
with `.`). No dependence on the absent `file` attribute. Regression test
added (`tests/unit/test_junit_reconciliation.py`). Verified end-to-end:
`mtest --tier 0-1` on mgf-auth went from quarantining the class-based
tests to **OK: 54 passed**. **Action for maintainer:** version-bump +
publish so consumers pulling from PyPI get the fix (and consider evicting
the stale `MISSING_FROM_XML` quarantine entries any affected repo
accumulated).

---

### `PAPER-97` — `mgf-common catch-up` defaults repos with **no Python at all** to `kind: library`, then surfaces the entire Python-library corpus as spurious findings

| Field | Value |
|---|---|
| Status | ✅ SHIPPED (Approach A + B) — mgf-common 0.44.5 |
| Severity | 🟡 High |
| Submitter | magogi-foundation-brand (agent session, 2026-06-02; hit on `mgf-common` 0.44.4) |
| Submitted | 2026-06-02 |
| Decision | accept — add a non-Python `docs` kind (A) + N/A-gate the Python-corpus checks (B); ship together |
| Decided on | 2026-06-03 |
| Scheduled for | 0.44.5 (patch — additive/backward-compatible; stays inside the cohort's `<0.45` pin window, no pin-walk) |
| Shipped in | 0.44.5 |

**Concern.** `catch-up`'s kind detection only distinguishes among *Python* project shapes (`library` / `cli` / `fastapi` / `django` / `service`). When a repo has no `pyproject.toml`, no `setup.py`, no `src/`, and **no `.py` files at all** — a documentation / brand / content repo — both the no-pyproject heuristic (`_detect_kind_no_pyproject`) and the with-pyproject path fall through to the `kind: library` default that PAPER-13 deliberately preserved "for legitimate libraries." `catch-up` then audits the repo *as a Python library* and emits a wall of findings that are all N/A: 🔴 missing `pyproject.toml` (×2), 🔴 `docs/claude/CLAUDE.md` missing → `--apply` generates the **Python-library** template (DP/EH/LG/CF/SC/TS/AP rules, the four gates, `mgf.common.bootstrap()` / `bootstrap_for_test()` examples) into a repo with zero code, 🔴 a DOC-17 block listing 11 "missing required artifacts for this consumer application" (CHANGELOG/CONTRIBUTING/SECURITY/FEEDBACK/STARTHERE/FEDERATION/DEPS/LICENSE/`docs/`/trackers), plus ⚠ four-gates, ⚠ test-supervisor, ⚠ 13-marker taxonomy, ⚠ Woodpecker. There is no "this isn't a Python project" detection and no way to mark the Python corpus N/A short of hand-authoring `MGF_STANDARDS_CONFORMANCE.md` (the PAPER-31 N/A-vs-declined distinction).

This is **not** PAPER-13 / PAPER-17: those fixed *Python-framework-vs-library* misclassification (`manage.py` → django, FastAPI-with-pyproject). The **no-Python** case still always lands on `library`, because every heuristic keys off Python markers.

**Why it matters.** It actively misleads agents instead of merely being noisy. In this session `catch-up --apply` wrote a 12 KB `docs/claude/CLAUDE.md` whose auto-managed block teaches `from mgf.common.observability import get_logger`, the four green gates, and `bootstrap_for_test()` — into a brand/docs repo with no Python. An agent "conforming" to that output would invent a phantom Python package and scaffold the DOC-17 library baseline that doesn't belong. The honest result is **"N/A — not a Python project,"** but the report reads as 11+ real defects, and `--apply` materialises an inapplicable template. ASPIRE-05 parked the strategic "language-aware vs language-agnostic standards" question until "2+ non-Python siblings exist" — this is a concrete second data point after `mgf-website-template` (PAPER-30): a non-Python *documentation/brand* repo that an agent was explicitly told to `/sync`.

**Concrete evidence.** `magogi-foundation-brand` @ `mgf-common` 0.44.4, 2026-06-02. `mgf-common catch-up` printed `Project: magogi-foundation-brand (kind: library)` followed by 2× 🔴, ~8 ⚠, and the DOC-17 "missing required artifact(s) for this consumer application" block (11 entries). The repo contains no `pyproject.toml`, no `src/`, and no `*.py` (it's `foundation/` markdown + `playbooks/` markdown + `brand/` SVG/HTML). The generated `docs/claude/CLAUDE.md` is the verbatim Python-library template. Kind-detection code: `_detect_kind_no_pyproject` / `_resolve_project_identity` in `src/mgf/common/cli/_catch_up.py` (per PAPER-13's `Shipped in` field).

**Suggested shape.**

- **Approach A — add a `non-python` (or `docs`) kind.** Detect it when there is no `pyproject.toml` / `setup.py` / `setup.cfg` **and** no `*.py` anywhere in the tree (only markdown/assets). For that kind, `catch-up` audits **only the language-agnostic subset**: agent orientation (`AGENTS.md` → `mgf-common agents`), the DOC-07 `CLAUDE.md` as a **thin-pointer** template (not the Python-rules template), and whichever root docs the project opts into — and reports the four-gates / test-supervisor / pyproject / `bootstrap()` / (optionally Woodpecker) items as **N/A**, not 🔴/⚠.
- **Approach B — keep the `library` default but gate the Python-corpus checks behind "Python detected."** A no-Python repo then gets a short, honest report (`kind: library (no Python detected — Python-specific checks N/A)`), and the generated CLAUDE.md auto-block carries an N/A banner instead of Python recipes. Mirrors PAPER-47's `applies_to:` filtering + PAPER-31's N/A separation.
- **Approach C — strategic (ASPIRE-05 follow-through).** A language dimension on kind (`{python, non-python} × shape`) so the standards corpus declares per-rule language applicability and `catch-up` filters by it. Bigger; the right long-term home for the language-aware bifurcation ASPIRE-05 describes.

Recommend **A or B now** (stops the false-positive wall and the misleading `--apply` template), **C** as the ASPIRE-05 follow-through once the language-aware split is designed. Related: PAPER-13 / PAPER-17 (kind detection), PAPER-31 (N/A vs declined), PAPER-47 (`applies_to:` filtering), PAPER-59 (missing `gui` kind), PAPER-90 (Python-centric marker taxonomy), PAPER-30 (first non-Python sibling), ASPIRE-05 (language-aware standards).

**Cost if deferred past v1.0.** Every non-Python Magogi repo (the marketing sites, brand, docs, and the polyglot `mgf-common-zig` sibling) gets a misleading conformance report and an agent-facing CLAUDE.md that teaches Python patterns into a non-Python tree. As more non-Python siblings land, the federation-wide "are we conformant?" signal degrades into permanent false 🔴 plus hand-waved carve-outs that drift. Adding a no-Python kind/branch now is far cheaper than retrofitting after agents have scaffolded phantom Python packages across the federation.

**Decision rationale.** *Triaged 2026-06-03 (supervisor).* **Accepted.** The
no-Python case is a real gap — PAPER-13/17 only split *Python* shapes, so every
heuristic still keys off Python markers and falls through to `library`.
`magogi-foundation-brand` is the concrete second non-Python data point after
`mgf-website-template` (PAPER-30): a docs/brand repo an agent was explicitly told
to `/sync`, and `catch-up --apply` materialised the verbatim Python-library
template into a tree with zero `.py`. Going with **A + B together**:
1. **Detect a `docs` (non-Python) kind** — no `pyproject.toml` / `setup.py` /
   `setup.cfg` **and** no `*.py` anywhere in the tree. Add `docs` to
   `init-project._KINDS` and to `_detect_kind` (ahead of the `library` fallback).
2. **Render the thin-pointer `CLAUDE.md` template** for that kind (DOC-07 pointer
   to `mgf-common agents`), **not** the Python-rules template — `--apply` must stop
   writing `bootstrap()` / four-gates / DP-EH-LG recipes into a non-Python repo.
3. **N/A-gate the language-specific checks** so they report **N/A**, not 🔴/⚠:
   four gates, test-supervisor (TS-22..27), 13-marker taxonomy, Woodpecker, the
   pyproject pin, and the DOC-17 Python-library artifact list
   (CHANGELOG/CONTRIBUTING/SECURITY/FEEDBACK/STARTHERE/FEDERATION/DEPS/trackers).
4. **Keep the language-agnostic items live:** `AGENTS.md` → `mgf-common agents`,
   the `CLAUDE.md` thin pointer, and `LICENSE` (every public repo should carry one).

Approach **C** (ASPIRE-05 language dimension on the standards corpus) stays the
strategic follow-through — deferred until the language-aware split is designed.
This is a cornerstone-CLI change that alters `catch-up` behaviour federation-wide,
so it ships as **its own focused, fully-tested change + release (0.45.0)** — not
bundled. `magogi-foundation-brand` is the canonical repro + first beneficiary
(its current recurring 🔴/⚠ are this bug, not real defects). **Governance note:**
that repo is confirmed a **NON-member of the library registry** — orientation-layer
only; the Python-conformance corpus + DOC-17 contract are N/A there by design.

**Retrospective.** *Shipped in mgf-common 0.44.5 (2026-06-03). Cut as a **patch**,
not 0.45.0: the change is additive/backward-compatible and 17 cohort siblings pin
`mgf-common ...<0.45`, so a 0.45.0 minor would have excluded all of them and forced
a federation-wide pin-walk; 0.44.5 ships it inside the current window with zero
cohort disruption.* `_detect_kind` now returns **`docs`** via a new
`_is_non_python_repo()` (no `pyproject.toml`/`setup.py`/`setup.cfg` **and** no `*.py`
anywhere, skip-dirs excluded, short-circuits on first hit). The duplicated finding
lists in `run_catch_up` (main + `--apply` verify) were refactored into one
`_collect_findings()` that, for a `docs` repo, reports the six language-specific
rows — pyproject pin, four gates, DOC-17 contract, test-supervisor, marker taxonomy,
Woodpecker — as `info`/**N/A** (helper `_na()`), not 🔴/⚠. `_render_claude_md` gained
a `docs` branch (`_CLAUDE_MD_DOCS_TEMPLATE`): a thin federation-orientation pointer
with the same BEGIN/END markers but **no Python recipes**, so `--apply` stops
materialising the four-gates/`bootstrap()` template into a code-free tree.
`catch-up --kind docs` is accepted; `init-project --kind docs` scaffolding is *not*
in scope (catch-up-only, per the paper). Verified on the `magogi-foundation-brand`
repro (now `kind: docs`, the 🔴 wall gone) + 9 new pins in `TestNonPythonDocsKind`
(incl. a non-vacuous control: the same repo audited as `library` still fails). Full
suite 2041 passed; ruff/mypy/lint-imports green. Approach C (ASPIRE-05) still
deferred.

---

### `PAPER-98` — `catch-up` reports the federation registry SNAPSHOT as current without flagging that the snapshot itself may be stale

| Field | Value |
|---|---|
| Status | ✅ SHIPPED on `main` (advisory, no network — see Decision); mgf-common release pending |
| Severity | 🟡 High |
| Submitter | mgf-standard (federation-sync red-team, agent session, 2026-06-06) |
| Submitted | 2026-06-06 |
| Decision | accept; keep `catch-up` OFFLINE (preserve PAPER-65/68) — make the snapshot's own staleness EXPLICIT in the output; the live latest-check stays agent-side (sync-prompt STEP-0 ASSERT-LATEST) |
| Decided on | 2026-06-06 |
| Scheduled for | next mgf-common patch |
| Shipped in | `_catch_up.py` federation-position advisory (this change) |

**Concern.** Every `catch-up` mgf-* currency check resolves OFFLINE from
`mgf.standard.libraries()` — a *snapshot* of the installed
`mgf-standard`, which "sees as far as that pin and no further"
(`_registry_latest_version` docstring). When the project's own
`mgf-standard` pin lags the latest release, the snapshot's "current"
versions are themselves stale, so `_check_federation_position` can report
"pins keep pace with the roster ✅" while the whole roster it compared against
is out of date. "In sync" silently means "in sync with my possibly-stale
snapshot," not "with the latest published."

**Why it matters.** It's a confident, green, potentially-WRONG currency verdict —
the failure the federation-sync guarantee exists to prevent (mgf-standard
FEEDBACK PAPER-05). A consumer behind a stale `mgf-standard` ceiling gets no
signal that the audit's frame of reference is itself outdated.

**Concrete evidence.** `src/mgf/common/cli/_catch_up.py`:
`_registry_latest_version` ("a *snapshot* … sees as far as that pin and no
further") + `_check_federation_position` "ok" branch returns "pins keep pace
with the federation roster (N members)" with no snapshot-version qualifier.

**Resolution (no-network).** Per the decision, `catch-up` stays offline (it does
NOT reintroduce the PAPER-65/68 outbound call). Instead it now ALWAYS states the
snapshot's provenance — "registry snapshot = mgf-standard `<installed>`; this
audit sees only as far as that pin; to confirm it's the latest published, run
`mgf-common sync-prompt` STEP-0 ASSERT-LATEST or check the Codeberg index" — on
both the ok and lagging branches, so the limitation is explicit, never silent.
The *live* registry-vs-installed comparison is performed agent-side by the
sync-prompt (mgf-standard 0.1.18), which already has the configured index
creds — keeping the CLI dependency-free and offline.

---

### `PAPER-99` — `doctor --standards` should gate `LESSON_LEARNED.md` presence once it is a binding required doc

| Field | Value |
|---|---|
| Status | ✅ SHIPPED — mgf-common 0.46.5 (DOC-19 promoted to MUST in standards v2.21; gate is corpus-aware/inert <0.1.20) |
| Severity | 🟢 Nice-to-have |
| Submitter | mgf-standard (federation-sync red-team, agent session, 2026-06-06) |
| Submitted | 2026-06-06 |
| Decision | agreed in principle; do NOT implement until `LESSON_LEARNED.md` is a binding required doc in mgf-standard (its FEEDBACK PAPER-03) — gating a non-binding doc would retro-fail every repo |
| Decided on | 2026-06-06 |
| Scheduled for | — (after mgf-standard PAPER-03 lands) |
| Shipped in | — |

**Concern.** mgf-standard added `LESSON_LEARNED.md` (an append-only per-repo
lessons log) as a recipe-level artifact and the sync-prompt now writes to it.
Once it is promoted to a *binding* required doc (mgf-standard PAPER-03), its
presence should be gated like the other root docs — otherwise the value-capture
artifact is optional in practice and quietly skipped.

**Why it matters.** The required-docs presence gate is what stops silent
omission of the mandated root docs; `LESSON_LEARNED.md` will need the same
backstop, or it won't be reliably present across the federation.

**Concrete evidence.** `src/mgf/common/cli/_doctor_standards.py` (and the
`catch-up` required-docs check) enumerate the DOC-01/07/08/14/15/16/17 set; no
`LESSON_LEARNED.md` row. Staged: must land AFTER the binding rule so it doesn't
retro-fail repos that haven't created the file yet.

---

### `PAPER-100` — ship a retry primitive (EH-11) + circuit-breaker note (EH-13)

| Field | Value |
|---|---|
| Status | SCHEDULED — 0.47.0 (MINOR; new public API) |
| Severity | 🟡 High |
| Submitter | mgf-common 2026-06-07 depth audit (self) |
| Submitted | 2026-06-07 |
| Decision | accept — provide a canonical retry helper in the cornerstone; defer to a deliberate MINOR (new public surface ⇒ cohort pin-walk), not a rushed audit-fix |
| Scheduled for | 0.47.0 |
| Shipped in | — |

**Concern.** EH-11 (a retry helper: exponential backoff + full jitter + finite
cap + per-attempt audit log) is a MUST whose §13.1 reference shape mgf-common
does not yet provide — consumers needing retry hand-roll it. EH-13 (circuit
breaker) is coupled: its bound is currently *recorded* (mgf-common performs no
outbound amplifying calls), which closes EH-13, but a shipped breaker is the
natural companion once retry exists.

**Why it matters.** The cornerstone is the right home for a common reliability
primitive; every consumer re-implementing backoff is the duplication the
federation exists to remove.

**Why deferred (not done in the 0.46.6 gap-closing pass).** A new public
`retry`/`breaker` surface is a MINOR bump ⇒ triggers a federation cohort
pin-walk (every sibling pins `mgf-common<0.47`). That is a deliberate release
wave, not something to bundle into a patch-level conformance-fix PR. Tracked
here for 0.47.0; mgf-common ledger §4 carries the ⏳.

---

## §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).

### PAPER-101 — `doctor --standards` should report graceful N/A on non-Python repos (not error)

| Field | Value |
|---|---|
| Status | OPEN |
| Submitter | mgf-standard (federation conformance sweep) on 2026-06-07 |
| Submitted | 2026-06-07 |
| Severity | 🟢 Nice-to-have |
| Federation paper | relayed by the supervisor from `mgf-standard/pending-feedback/mgf-common.md` (FB-05) |

- **Severity:** 🟢 Nice-to-have
- **Filed by:** mgf-standard (federation conformance sweep) on 2026-06-07
- **What hurts:** `mgf-common doctor --standards` requires a `pyproject.toml`
  with `[project]`; on a **non-Python** federation member it prints "cannot
  detect project shape" and exits non-zero. The 6 Zig libs (`build.zig.zon`:
  mgf-common-zig, mgf-common-identity, mgf-hrb-{model,kabi,cap,rt}) and the
  `mgf-website-template` cookiecutter hit this. It reads as a failure when the
  honest result is **"N/A — not a Python project."**
- **Why it matters:** a federation-wide `doctor --standards` sweep shows 7
  members as errors that are actually just out-of-scope for a Python audit —
  noisy + misleading, exactly like the `catch-up` case PAPER-97 fixed.
- **Evidence:** `_discover_project` returns None → `doctor_standards()` prints
  the shape error + returns EXIT_OPERATION_ERROR; repros on all 7 non-Python
  members (verified 2026-06-07).
- **Suggested shape:** mirror PAPER-97 — detect a non-Python member (no
  `pyproject [project]`, presence of `build.zig.zon` / `cookiecutter.json`) and
  emit a single "N/A — not a Python project (no Python conformance surface)"
  finding with exit 0, instead of the shape error. The DOC-15/DOC-19 root-doc
  facts (ledger, LESSON_LEARNED.md) are language-agnostic and could still be
  checked, but the Python code-level checks (AP-09/AP-13/TS-04/DP-12) are N/A.

## Relayed (closed)

## Relayed (closed)

- **2026-06-07 — `catch-up` live latest-check** → relayed as **mgf-common
  `PAPER-98`** (merged, PR #9, commit `100b52e`). Resolved *no-network*: the CLI
  stays offline and now states its registry-SNAPSHOT provenance explicitly; the
  live latest-check is performed agent-side by the sync-prompt's STEP-0
  ASSERT-LATEST. (mgf-standard FEEDBACK PAPER-05.)
- **2026-06-07 — gate `LESSON_LEARNED.md` in `doctor --standards`** → relayed as
  **mgf-common `PAPER-99`** (DEFERRED — blocked on `LESSON_LEARNED.md` becoming
  a binding required doc here, mgf-standard FEEDBACK PAPER-03; gating a
  non-binding doc would retro-fail every repo).
- **2026-06-07 — re-sync `mgf-common/.claude/commands/sync.md`** → **NOT relayed;
  no mgf-common action needed.** Verified the command is already a thin
  *delegator* (`Run mgf-common sync-prompt …`), not an inline copy, so it never
  drifts. The real defect was a stale Note in this repo's
  `federation-sync-prompt.md` recipe (it described an inline-copy model) — fixed
  here, not in mgf-common.
