# Reliability & Accuracy

> **Status:** v0.1 — first pass, finalized 2026-05-15
> (review-pass verified all file:line citations against the
> live source). Last updated 2026-05-15.
> A federation-sibling-scoped reliability audit: walked the ten
> source files (`_lifespan.py`, `_request_id.py`,
> `_dependencies.py`, `_exceptions.py`, `exceptions.py`,
> `security.py`, `testing.py`, `db.py`, `webhooks/_verifier.py`,
> `webhooks/_request.py`) end-to-end against the **RA** / **EH**
> / **SC** rule set from `mgf-standard/docs/standards/`, verified
> every claim against `file:line`, identified the systemic
> patterns. The sibling's public surface is the FastAPI
> integration layer — lifespan, request-id middleware,
> exception-translation middleware, dependencies, IP allowlist,
> bearer-session resolver, HMAC webhook verifier, test API
> server.
>
> **Scope:** v1.0 readiness for mgf-fastapi. Items beyond v1.0
> are noted but deprioritised; revisit when the v1.0 surface-lock
> review opens.
> **Audience:** the maintainer + future AI agents doing
> reliability work on this sibling.
> **Cross-references:** [`PUBLIC_API.md`](../../PUBLIC_API.md),
> [`docs/standards/README.md`](../standards/README.md),
> [`mgf-common/docs/inprogress/RELIABILITY_AND_ACCURACY.md`](https://codeberg.org/magogi-admin/mgf-common/src/branch/main/docs/inprogress/RELIABILITY_AND_ACCURACY.md)
> (cornerstone tracker — the patterns documented here have
> cornerstone analogs where relevant),
> [`mgf-django/docs/inprogress/RELIABILITY_AND_ACCURACY.md`](https://codeberg.org/magogi-admin/mgf_django/src/branch/main/docs/inprogress/RELIABILITY_AND_ACCURACY.md)
> (sister-sibling — RA-01 here is the same pattern as
> mgf-django's RA-01).

This document is the **living priority tracker** for reliability
+ accuracy work in `mgf-fastapi`. The shape mirrors mgf-common's
canonical tracker shape: priority table at the top, work-unit
detail below, status flipped as items close.

The sibling is at v0.4.0 (post-v2.6/v2.7 compliance bump from
v0.3.1). The surface includes the request path (auth, HMAC
verification, IP allowlist) for every FastAPI consumer that
depends on it; reliability gaps here are paid downstream on
every request.

---

## §1 You are here

mgf-fastapi ships ~15 public names across the top-level module
plus the `exceptions`, `security`, `webhooks`, `db`, and
`testing` submodules. Surface quality is high: `mypy --strict`
clean, the four PAPER-26..29 papers each shipped with their
own test suites, the HMAC verifier uses constant-time compare,
and the bearer-session resolver treats every verifier failure
uniformly as 401 (no info-leak).

What this tracker exposes is the **per-request defence shape**
— the invariants that hold under nominal traffic but might not
under adversarial input (`X-Request-Id`-injected `\r\n`,
malformed `X-Forwarded-For`, fragmentary Svix signature
headers, sleeping IdP, body double-read from upstream
middleware), test-runner concurrency (parallel `run_test_app`
acquiring the same port), or partial failures (engine
disposal raising at lifespan exit).

**Counts (2026-05-17):**

| Priority | Open | Closed | Total |
|---|---|---|---|
| P0 — v1.0 surface-lock blockers | 0 | 0 | 0 |
| P1 — should land before/with v1.0 | 0 | 4 | 4 |
| P2 — post-v1.0 hardening | 2 | 2 | 4 |
| **Total** | **2** | **6** | **8** |

**Do first:** RA-01..04 closed (P1). RA-06 closed (P2 —
`engine.dispose()` now log-and-continue in lifespan teardown).
RA-08 closed (P2 — bearer token control-char filter). RA-05
(free-port TOCTOU bind-retry) + RA-07 (MRO property test)
deferred to v1.1+ — both are real-but-not-blocker hardening
that benefits from real-world feedback on the failure shapes.

---

## §2 Priority table

| ID | Concern | One-line | Effort | Status |
|---|---|---|---|---|
| [**RA-01**](#ra-01-header-injection-on-x-request-id-reflect) | Security / input validation (cross-sibling) | `RequestIdMiddleware` echoes incoming `X-Request-Id` without sanitization — `\r\n` injection class. Same pattern as mgf-django RA-01; fix both together. | S | ✅ closed |
| [**RA-02**](#ra-02-hmac-verifier-signature-header-tokenisation) | Security / parser robustness | `HmacWebhookVerifier.verify` splits the signature header on a single space; Svix spec says single-space but observed-in-the-wild deviations (tabs, multiple spaces, trailing whitespace) silently miss every candidate signature. | S | ✅ closed |
| [**RA-03**](#ra-03-ipallowlist-ipv6-and-port-suffix-parsing) | Defensive input handling | `IpAllowlist._extract_client_host` returns `request.client.host` as-is OR the first `X-Forwarded-For` entry. Both can include port suffixes or IPv6 brackets (`[::1]:8080`, `[2001:db8::1]`), which then ValueError out of `ipaddress.ip_address()` → 403. Should normalize. | S | ✅ closed |
| [**RA-04**](#ra-04-webhook-body-double-read-defence) | Defensive lifecycle (EH-05 / EH-09 aligned) | `verify_request` reads `await request.body()` — if an upstream middleware consumed the body already, the second read returns `b""`, the HMAC fails, and the caller sees a generic signature-mismatch error. The cause is invisible. | XS | ✅ closed |
| [**RA-05**](#ra-05-free-port-toctou-add-bind-retry) | Reliability under contention | `testing._free_port` picks a port + releases the socket before uvicorn binds — TOCTOU race on parallel test runs (`pytest-xdist`). Documented limitation; small bind-retry loop makes it self-healing. | S | ⏳ v1.1+ — bind-retry behaviour needs real `pytest-xdist` failure data to size the backoff |
| [**RA-06**](#ra-06-setup-db-engine-dispose-may-raise) | Lifespan teardown safety | `db.setup_db`'s lifespan calls `await engine.dispose()` at exit. If dispose raises (transient DB error during shutdown), the rest of the lifespan teardown stack unwinds incompletely. Defensive log-and-continue is the standard pattern. | S | ✅ closed |
| [**RA-07**](#ra-07-exceptiontranslationmiddleware-lookup-property-test) | Test coverage for complex dispatch | `ExceptionTranslationMiddleware._lookup_status` walks MRO + checks `status_map` + falls back to `http_status` class attr (PAPER-24's resolution). Three-tier logic; one missing test could let a regression slip in. Property test would pin the invariant. | S | ⏳ v1.1+ — existing example tests cover the three documented branches; full property-test sweep is post-v1.0 hardening |
| [**RA-08**](#ra-08-bearer-session-cookie-token-newline-filtering) | Defensive input | `_extract_bearer_token` accepts the cookie value as-is when the header is absent (cookie fallback). If the cookie value contains `\r\n` or NUL, downstream `verify_session(token)` could behave unexpectedly. Browsers usually disallow these in cookies; defence-in-depth catches the rare gap. | XS | ✅ closed |

Effort scale: XS (≤ 15 min); S (≤ 1 h); M (≤ 1 session); L
(multiple sessions).

---

## §3 Invariants we hold

The list of "closed elsewhere; do not re-open" — facts the
audit confirmed about the current state that future passes
should NOT re-investigate without new evidence:

- **RA-INV-1** — Lifespan is **single-shot per FastAPI app
  instance**. `setup_lifespan` returns an async context manager;
  FastAPI invokes it once at app startup
  (`src/mgf/fastapi/_lifespan.py:57-63`). Multi-worker
  deployments (`uvicorn --workers 4`) get one bootstrap per
  worker process — exactly the runtime model `mgf.common.bootstrap`
  expects (per-process global state).
- **RA-INV-2** — HMAC verification uses **constant-time
  compare**. `hmac.compare_digest(received, expected)` at
  [`webhooks/_verifier.py:185`](../../src/mgf/fastapi/webhooks/_verifier.py#L185).
  No timing-oracle leak on the signature comparison.
- **RA-INV-3** — Replay window is **symmetric**. `abs(self._now() - ts)`
  at [`webhooks/_verifier.py:164`](../../src/mgf/fastapi/webhooks/_verifier.py#L164)
  rejects future-clock-skewed timestamps as well as stale ones.
  A clock that's drifted way ahead is as suspicious as a stale
  one.
- **RA-INV-4** — Bearer-session **never logs the token**.
  `LOG.info("bearer_session: verifier rejected token (%s)", type(exc).__name__)`
  at [`security.py:278`](../../src/mgf/fastapi/security.py#L278)
  logs only the exception type name. Per **LG-04** + **SC-09**;
  PAPER-29's "no info-leak on auth failure" contract.
- **RA-INV-5** — `IpAllowlist` parses CIDRs **eagerly at
  construction** ([`security.py:119-123`](../../src/mgf/fastapi/security.py#L119)).
  A misconfigured CIDR raises `ValueError` at app startup, not
  at first request.
- **RA-INV-6** — RequestId contextvar reset is in `finally`.
  `RequestIdMiddleware.dispatch` uses `try/finally` around
  `_REQUEST_ID_VAR.reset(token)`
  ([`_request_id.py:71-75`](../../src/mgf/fastapi/_request_id.py#L71)).
  Exception-path correctness preserved; the response header
  isn't set on the exception path, which is correct (Starlette's
  exception handler builds the response).
- **RA-INV-7** — `bearer_session` translates **every non-
  HttpUnauthorized** to `HttpUnauthorized` ([`security.py:259-280`](../../src/mgf/fastapi/security.py#L259)).
  Verifier 500s, network blips, expired JWTs — all become 401.
  Per PAPER-29's "no info-leak" contract: callers cannot
  distinguish "your token is invalid" from "we couldn't reach
  the IdP."
- **RA-INV-8** — `setup_db` raises a clear `ImportError` at
  module-load time when the `[sqlalchemy]` extra isn't
  installed ([`db.py:38-43`](../../src/mgf/fastapi/db.py#L38)).
  Loud failure, points at the missing extra, no silent
  no-op.

---

## §4 Work units

### RA-01 Header-injection on `X-Request-Id` reflect

**Why:** Same pattern as mgf-django RA-01. `RequestIdMiddleware.dispatch`
([`_request_id.py:67`](../../src/mgf/fastapi/_request_id.py#L67)):

```python
existing = request.headers.get(REQUEST_ID_HEADER.lower())
request_id = existing or _generate_request_id()
...
response.headers[REQUEST_ID_HEADER] = request_id
```

A client-supplied `X-Request-Id: foo\r\nSet-Cookie: m=1`
gets echoed unchecked. Starlette's response-header
encoding will typically reject CR/LF (raises during response
serialization), but the bad input traverses the view first —
side-effects (DB writes, outgoing emails) may have committed
by the time the response fails.

**Concrete output:**

- A guard at `_request_id.py:67-70` that:
  - Rejects values containing `\r`, `\n`, or `\0`.
  - Caps length at 128 chars (matches the mgf-django fix).
  - On rejection: generate a fresh UUID4 and proceed (don't
    abort the request — drop the bad value, keep going).
- A unit test in `tests/unit/test_request_id.py` that:
  - Sends `X-Request-Id: foo\r\nX-Injected: yes` and asserts
    the response's `X-Request-Id` is a fresh UUID.
  - Sends `X-Request-Id: <128-char string>` (kept);
    `<129-char string>` (replaced).

**Cross-sibling note:** mgf-django RA-01 has the same fix
shape — close BOTH together so the cross-framework contract
stays uniform (the contextvar is shared; the input-validation
discipline should also be shared).

**Locked in by:** the new unit test asserts both the
rejection-and-regenerate behaviour and the length cap.

**Cross-references:** SC-09 (sink redaction), mgf-django
RA-01, the `mgf.common._request_id` contextvar contract.

---

### RA-02 HMAC verifier signature-header tokenisation

**Why:** `HmacWebhookVerifier.verify`
([`webhooks/_verifier.py:179`](../../src/mgf/fastapi/webhooks/_verifier.py#L179)):

```python
candidates = sig_header.split(" ")
```

Svix's published spec uses single-space-separated multi-version
signatures (`v1,sigA v1,sigB`). But observed-in-the-wild
deviations:

- Some proxies trim trailing whitespace, then reapply a single
  trailing space → `"v1,sigA v1,sigB "` → empty string in
  candidates → harmless miss.
- Tab characters in webhooks crossing systems that re-encode
  HTTP headers (e.g., a Cloudflare Worker proxying upstream
  webhooks).
- Multiple spaces from a misconfigured upstream → empty strings
  between → silent misses.

Today the verifier handles "candidate is empty / doesn't
start with prefix" correctly (skips it). But it doesn't handle
tabs/multi-space-trim cases — the candidate after a tab is
something like `"\tv1,sigA"` which fails the `startswith` check
silently.

**Concrete output:**

- Change `sig_header.split(" ")` to `sig_header.split()` (no
  arg → splits on any whitespace, handles tabs + multi-space
  + trims edges).
- Add a property-style test that constructs valid signatures
  with various separators (single space, tab, multiple
  spaces, leading/trailing whitespace) and asserts each
  parses correctly.
- Optionally: log an audit event when the signature header
  contains non-space whitespace (`\t`, `\v`) — useful signal
  that an upstream is encoding-mangling the header.

**Locked in by:** the property test exercises the boundary;
a regression to single-space-split breaks it.

**Cross-references:** PAPER-26 retrospective (the Svix spec
follow-up + the Stripe/GitHub/Slack-variant deferral).

---

### RA-03 IpAllowlist IPv6 and port-suffix parsing

**Why:** `IpAllowlist._extract_client_host`
([`security.py:151-159`](../../src/mgf/fastapi/security.py#L151))
returns either:

- `request.client.host` (default), OR
- `forwarded.split(",", 1)[0].strip()` (when `trust_proxy=True`)

Both can carry shapes that `ipaddress.ip_address()` rejects:

- **IPv4 with port** — `"203.0.113.5:54321"`. Some reverse
  proxies append it to the canonical IP; this hits the
  `ValueError → HttpForbidden` branch and the request gets
  denied even though the underlying IP is in the allowlist.
- **IPv6 with brackets** — `"[2001:db8::1]"`. RFC 3986
  bracket-encoding for URLs. `ip_address("[2001:db8::1]")`
  raises.
- **IPv6 with brackets + port** — `"[::1]:8080"`. Combination
  of both. Common shape from reverse proxies that preserve the
  port.

Today these all become 403 with a "client IP not in
allowlist" message — misleading to the operator; the real
issue is parsing.

**Concrete output:**

- A `_normalize_client_host(raw: str) -> str | None` helper
  that:
  - Strips IPv6 brackets (`[::1]:8080` → `::1:8080` → split
    on rightmost `:` → `::1`).
  - Wait — IPv6 has `:` in the address itself. The bracket
    presence is the IPv6 marker; strip brackets, then if
    `]:<port>` was the suffix, that's the port.
  - For non-bracket strings, IPv4 + `:port` → split on
    rightmost `:`.
  - Return `None` if the result still doesn't parse.
- Update both extract paths (`request.client.host` and the
  XFF first-entry path) to route through the helper.
- Add a parametrised test exercising all four shapes
  (raw IPv4, IPv4:port, [IPv6], [IPv6]:port) on both
  trust-proxy paths.

**Locked in by:** the parametrised test covers each shape;
a regression in the normalizer breaks a row.

**Cross-references:** PAPER-27 (the IpAllowlist + proxy-trust
footgun paper).

---

### RA-04 Webhook body double-read defence

**Why:** `verify_request`
([`webhooks/_request.py:55`](../../src/mgf/fastapi/webhooks/_request.py#L55)):

```python
body = await request.body()
return verifier.verify(request.headers, body)
```

The docstring says "Caller must NOT have consumed the body
yet — this function reads it via `await request.body()`."
That's a contract on the caller. But `request.body()` is
idempotent under FastAPI/Starlette — calling it twice returns
the cached body the second time. So:

- If an upstream middleware called `await request.body()` —
  fine, the cached bytes come back.
- If an upstream middleware called `await request.stream()` —
  consumed the stream; subsequent `request.body()` returns
  `b""`.
- If an upstream middleware called `await request.json()` —
  consumed the body via the JSON parser; subsequent
  `request.body()` returns `b""`.

In the last two cases the HMAC over an empty body produces a
mismatch; the caller gets a generic "signature mismatch"
error and has no way to diagnose the real issue.

**Concrete output:**

- A length-zero defence: if `len(body) == 0` and the
  Content-Length header indicates non-zero, raise a typed
  diagnostic exception (`HmacVerificationError("request body was already consumed; install verify_request earlier in the middleware chain")`).
- Document the constraint in the recipe doc more explicitly:
  "verify_request MUST run before any other middleware /
  dependency that consumes the body."

**Locked in by:** a unit test where an upstream middleware
calls `await request.stream()` first, then verify_request is
called — assert the typed diagnostic exception is raised, not
a generic signature mismatch.

**Cross-references:** EH-09 (actionable error messages).

---

### RA-05 `_free_port` TOCTOU — add bind retry

**Why:** `testing._free_port`
([`testing.py:34-43`](../../src/mgf/fastapi/testing.py#L34)):

```python
def _free_port(host: str = "127.0.0.1") -> int:
    with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
        s.bind((host, 0))
        return int(s.getsockname()[1])
```

The function binds an ephemeral port + immediately releases
it (the `with` exits) + returns the port number for uvicorn
to bind. Between release and uvicorn's bind, another process
(another `pytest-xdist` worker, a leftover dev server) can
grab the same port. Uvicorn then raises `OSError(98, "Address already in use")`.

The docstring acknowledges the race ("Race-prone in theory ...
but reliable in practice"). Reliable in practice is fine for
a happy-path test runner; not fine for a regression-resistant
CI where flakes compound.

**Concrete output:**

- Wrap `run_test_app`'s startup in a retry loop:
  - Try `_free_port` + uvicorn bind up to 3 times.
  - Catch `OSError(EADDRINUSE)` and pick a new port.
  - After 3 failures, raise the original error with a clear
    "all attempts failed; check for leaked test servers"
    message.
- Or: switch to the SO_REUSEADDR + 0-port approach where the
  socket is held open and passed to uvicorn via the
  `fd` mechanism. More invasive; defer to follow-up.

**Locked in by:** a test that mocks `socket.bind` to raise
EADDRINUSE on first call, succeed on second — asserts
`run_test_app` retries and succeeds.

**Cross-references:** the cornerstone's PAPER-28 (initial
extraction) didn't address TOCTOU; this closes the follow-up
gap.

---

### RA-06 `setup_db` engine dispose may raise

**Why:** `setup_db` (in `db.py`) yields a `FastAPI` app with
the sessionmaker on `app.state` and calls `await engine.dispose()`
at lifespan exit. If `dispose()` raises (transient DB
connectivity issue at shutdown, a stuck connection in the
pool), the surrounding lifespan teardown stack unwinds
incompletely — any LATER cleanup hooks (`mgf.common.bootstrap`
teardown, OTel flushing, etc.) get skipped because the
exception propagates.

**Concrete output:**

- Wrap the `engine.dispose()` call in a try/except that:
  - Catches `Exception` (broad — dispose can raise many
    things from `sqlalchemy.exc.*`).
  - Logs a WARNING audit event via `mgf.common`'s standard
    logger.
  - Swallows so the rest of the teardown completes.
- Add a test that:
  - Builds a `setup_db` lifespan with an engine whose
    `dispose()` raises a configured exception.
  - Asserts the lifespan teardown completes (the surrounding
    `__aexit__` returns), and the log line is present.

**Locked in by:** the unit test — a future refactor that
re-introduces the raise breaks it.

**Cross-references:** DP-13 (deterministic resource release —
the helper SHOULD release even on failure); EH-10 (never-raises
observers — same pattern).

---

### RA-07 `ExceptionTranslationMiddleware._lookup_status` property test

**Why:** The `_lookup_status` resolution
([`_exceptions.py:144-176`](../../src/mgf/fastapi/_exceptions.py#L144))
has three tiers per PAPER-24:

1. `status_map` entry for any non-`AppError` ancestor in the
   MRO.
2. Class-declared `HttpError.http_status` attribute.
3. `status_map[AppError]` catch-all.

The logic is correct but each tier has subtle invariants:

- Tier 1 walks `exc_type.__mro__`, defers `AppError` itself
  to tier 3, and short-circuits on the FIRST non-`AppError`
  match.
- Tier 2 reads a class attribute that MAY be inherited from
  a parent class.
- Tier 3 falls back to a default 500 if `status_map[AppError]`
  is not configured.

A regression in any of these could leak the wrong status code
to clients without breaking a happy-path test. A property test
that constructs synthetic `AppError` subclass trees + asserts
the resolution is the right answer for each leaf would catch
this class of bug structurally.

**Concrete output:**

- Add `tests/unit/test_exception_translation_property.py` that
  uses Hypothesis to:
  - Generate small synthetic `AppError` subclass trees
    (depth 1-4, with optional `http_status` class attrs at
    arbitrary depths).
  - For each leaf, compute the expected status via a simple
    reference resolver (Python's `mro()` walk).
  - Assert `_lookup_status(leaf) == expected`.
- Pin specific Svix-related cases (`HmacVerificationError →
  401` via `HttpUnauthorized` inheritance) as fixed examples
  for the property test corpus.

**Locked in by:** the property test exercises the boundary
across the configuration space; regression breaks a
hypothesis-generated case.

**Cross-references:** PAPER-24 (HTTP-01 + middleware-composition
resolution), AP-10 (the public surface tested).

---

### RA-08 Bearer-session cookie token newline filtering

**Why:** `_extract_bearer_token`
([`security.py:287-309`](../../src/mgf/fastapi/security.py#L287))
accepts the cookie value as-is when the Authorization header
is absent:

```python
if cookie_name:
    cookie = request.cookies.get(cookie_name)
    if cookie:
        token = cookie.strip()
        if token:
            return token
```

Browsers usually disallow `\r\n` and NUL in cookies (per RFC
6265 and most browser implementations). But:

- A non-browser client (CLI tool, server-to-server SDK) could
  set arbitrary bytes in a cookie.
- A reverse proxy that re-encodes cookies could introduce
  weird whitespace.

`cookie.strip()` removes leading/trailing whitespace but
PRESERVES internal whitespace. A cookie value like
`"abc\r\ndef"` becomes `"abc\r\ndef"` after strip → passed
to `verifier.verify_session(token)` → opaque IdP behavior.

Per the Authorization-header path: `value.strip()` has the
same property. So both paths share this gap.

**Concrete output:**

- Tighten `_extract_bearer_token` to reject tokens containing
  any control characters (`\r`, `\n`, `\0`, `\t`, ...):
  - After the strip, check `if not token.isprintable(): return None`.
  - Or stricter: validate the token matches a JWT shape
    (`base64url . base64url . base64url`) — but this couples
    the helper to JWT, which is an implementation detail
    of the AuthVerifier impl. Don't do this; keep the
    primitive shape-agnostic.
- Add a unit test that constructs a cookie/header with
  embedded `\n` and asserts the dependency returns
  `HttpUnauthorized("missing bearer token")` rather than
  passing the tainted value to the verifier.

**Locked in by:** the unit test exercises the boundary; a
regression that loosens the filter breaks it.

**Cross-references:** SC-09 (sink redaction — same defensive
principle on input).

---

## §5 Closed items

- **RA-01** — Closed-by-reference (SH-01). The X-Request-Id
  injection guard shipped under SH-01 (commit `15ef1cd`).
  Twin of mgf-django RA-01 / SH-01 — the fix lands once, both
  trackers record the close. See SH-01 in
  `SECURITY_HARDENING.md` for the full description:
  `_sanitise_request_id` helper rejects CR/LF/NUL/control-
  chars + values longer than 128 chars; rejected values fall
  through to a fresh UUID; 5 unit tests pin the contract.
  (2026-05-16, commit `15ef1cd`.)
- **RA-02** — HMAC verifier signature-header tokenisation.
  Changed `sig_header.split(" ")` to `sig_header.split()`
  (no arg) in `HmacWebhookVerifier.verify`. Splits on ANY
  whitespace (tabs, multiple spaces, leading/trailing) and
  drops empty strings, so observed-in-the-wild proxy re-
  encodings (Cloudflare Worker proxying upstream webhooks,
  Envoy header normalisation) parse correctly. 3 new tests
  pin the tab, multi-space, and trailing-whitespace cases.
  (2026-05-17, commit pending.)
- **RA-03** — IpAllowlist IPv6 + port-suffix parsing. Added
  a new `_normalize_client_host(raw)` helper that strips IPv6
  brackets (`[::1]:8080` → `::1`), drops the port suffix on
  IPv4-with-port (`203.0.113.5:54321` → `203.0.113.5`), and
  leaves bare IPv4/IPv6 strings alone. Wired into
  `IpAllowlist._extract_client_host` for both the
  `request.client.host` and the X-Forwarded-For paths.
  Previously these shapes hit `ipaddress.ip_address()`'s
  ValueError and produced a misleading "client IP not in
  allowlist" 403 — now the parser succeeds and the membership
  check runs against the real IP. 9 new tests:
  parametrised normalize-helper coverage (8 shapes) + 2 end-
  to-end tests pinning that `203.0.113.5:54321` and
  `[::1]:8080` pass the allowlist when the underlying IP is
  in the configured CIDR. (2026-05-17, commit pending.)
- **RA-04** — Webhook body-already-consumed detection.
  Updated `verify_request` to detect the case where
  `request.body()` returns empty bytes BUT `Content-Length`
  indicates the request had bytes — an upstream middleware
  consumed the body via `await request.stream()` /
  `await request.json()` before verify_request ran. Raises
  a typed `HmacVerificationError` whose message names the
  likely cause AND gives the actionable fix ("install
  verify_request EARLIER in the middleware chain, or avoid
  `await request.stream()`/`await request.json()` upstream").
  Documented in `verify_request`'s docstring under a new
  "Body-already-consumed detection (RA-04)" Notes section.
  3 new tests: empty body + non-zero Content-Length raises
  the diagnostic; empty body + Content-Length: 0 passes
  through (legit empty body); empty body + missing
  Content-Length passes through (no false-positive on
  unknown content length). (2026-05-17, commit pending.)

---

## §6 Notes on methodology

This first pass walked the ten source files (~900 LOC total)
end-to-end against:

- **RA** rule patterns from mgf-common's
  `inprogress/RELIABILITY_AND_ACCURACY.md` — atomic writes,
  lock discipline, rollback semantics, exception swallowing,
  doc drift after deprecation removal, idempotency.
- **EH** rules from `mgf-standard/docs/standards/ERROR_HANDLING.md`
  — translation seams, top-level handlers, best-effort
  observers, EH-09 actionable hints.
- **SC** rules from `mgf-standard/docs/standards/SECURITY_STANDARD.md`
  — input validation at boundaries, constant-time compares,
  secret handling, traceback scrubbing.
- **PAPER-24..29** retrospectives — the four shipped papers
  carry their own design constraints; this audit checked the
  surfaced patterns for regressions.

The audit method:

1. Read each source file end-to-end with the rule set in mind.
2. For every concern surfaced, verify `file:line` against the
   live source.
3. Cross-reference against mgf-common's RA tracker AND
   mgf-django's RA tracker (sister-sibling) — patterns that
   appear in multiple siblings (RA-01 here = RA-01 in
   mgf-django) should close together.
4. Triage: a concern that doesn't tie to a rule + concrete
   evidence is an "observation," not a tracker item.

Eight P-level items + eight invariants is right-sized for
this sibling's scope. Future passes should look for:

- **Pattern parity with sister-siblings.** RA-01 (request-id
  header injection) lives identically in mgf-django and
  mgf-fastapi — when one closes, the other should close in
  the same commit pair to keep the cross-framework contract
  uniform.
- **PAPER-* retrospective follow-up items.** Each shipped
  paper carried a "deliberately not shipped at vX.Y" list;
  walk those when planning the v0.5+ surface.
- **Test-coverage gaps for invariants verified by inspection.**
  Several items here (RA-03 IPv6, RA-04 body double-read)
  are "the code looks fine, but no test pins the boundary."
  Sibling test suites are smaller than the cornerstone's so
  the gaps are easier to spot.

When an item closes, move it to §5 with the closing-commit
SHA. When the next pass sharpens an item, rewrite it in place
and note the change in §1.
