diff --git a/planning/audit/2026-06-12-delta-audit.md b/planning/audit/2026-06-12-delta-audit.md
new file mode 100644
index 0000000..ce5bbc3
--- /dev/null
+++ b/planning/audit/2026-06-12-delta-audit.md
@@ -0,0 +1,359 @@
+# httpware delta audit — 2026-06-12 (0.9.0 multi-decoder epic)
+
+**Status:** complete
+**Baseline:** 0.8.6 → current HEAD `f8bb0e5`
+**Spec:** [planning/specs/2026-06-12-delta-audit-design.md](../specs/2026-06-12-delta-audit-design.md)
+**Plan:** [planning/plans/2026-06-12-delta-audit-plan.md](../plans/2026-06-12-delta-audit-plan.md)
+
+## Summary
+
+- Blockers: 0
+- High: 2
+- Medium: 1
+- Low: 3
+- Nits: 8
+
+No blockers surfaced in the 0.9.0 multi-decoder delta. The most severe finding is a
+tie between two High items, but the one with the broadest user-facing blast radius is
+**`src/httpware/decoders/msgspec.py:48` — `MsgspecDecoder.can_decode` returns `True`
+for parameterized containers whose element type is a pydantic `BaseModel`** (e.g.
+`list[PUser]`): the `CustomType` guard only inspects the top-level `type_info`, so the
+pre-flight `MissingDecoderError` check is bypassed, a real HTTP request is sent, and the
+false-positive is cached per instance so every subsequent request with that model type
+repeats the failure as a `DecodeError`. The companion High is documentation: `CLAUDE.md`
+Seam B still describes the pre-0.9.0 single-decoder `decode(content, model)` contract with
+no mention of `can_decode`, so an agent implementing a custom decoder from that reference
+ships an interface that `AttributeError`s at dispatch.
+
+## Findings
+
+### High
+
+#### MsgspecDecoder.can_decode false-positive for parameterized containers of pydantic BaseModels
+
+`src/httpware/decoders/msgspec.py:48`
+
+The `CustomType` guard in `can_decode` inspects only the top-level `type_info` result.
+For `list[PUser]`, `dict[str, PUser]`, `Optional[PUser]`, or `tuple[PUser, int]`,
+`type_info` returns `ListType`/`DictType`/`UnionType` (not `CustomType`), so the guard
+passes and `msgspec.json.Decoder` builds silently — `can_decode` returns `True`, the
+`MissingDecoderError` pre-flight is bypassed, a real request is sent, and `decode` then
+raises `ValidationError` (surfaced as `DecodeError`). The false-positive is cached in
+`self._msgspec_decoders`, so the failure repeats for every later request of that type.
+
+```python
+ def can_decode(self, model: type) -> bool:
+ try:
+ info = msgspec.inspect.type_info(model)
+ except Exception: # noqa: BLE001 — can_decode is a probe; any failure means no
+ return False
+ if isinstance(info, msgspec.inspect.CustomType):
+ return False
+ try:
+ self._get_msgspec_decoder(model)
+ except Exception: # noqa: BLE001 — can_decode is a probe; any failure means no
+ return False
+ return True
+```
+
+Verifier consensus: 3/3 (code_reality + reproducer + spec_grounded). Suggested direction:
+recurse through parameterized `type_info` nodes and reject when any element is a
+`CustomType`, so `MissingDecoderError` fires before a request is sent.
+
+#### CLAUDE.md Seam B description is stale for 0.9.0 (single-decoder contract)
+
+`CLAUDE.md:84`
+
+`CLAUDE.md` line 84 describes Seam B as a single `ResponseDecoder` with only
+`decode(content: bytes, model: type[T]) -> T` — no `can_decode`, no list contract, no
+pre-flight `MissingDecoderError`. 0.9.0 changed Seam B to a list of decoders dispatched
+via `can_decode`, so an agent implementing a decoder from this reference produces an
+interface missing `can_decode`. At dispatch the client calls `decoder.can_decode(model)`,
+which `AttributeError`s at runtime.
+
+```text
+2. **Seam B** — `Client`/`AsyncClient` ↔ `ResponseDecoder` — called when `response_model` is provided. Signature: `decode(content: bytes, model: type[T]) -> T`. Implementations of both `send` methods call the decoder identically.
+```
+
+Verifier consensus: 3/3 (code_reality + spec_grounded + spec_grounded). Suggested
+direction: rewrite Seam B to describe the `decoders=[...]` list, the `can_decode` dispatch
+protocol, and when `MissingDecoderError` fires.
+
+### Medium
+
+#### Shared-shape "first decoder wins" tests cannot distinguish which decoder ran
+
+`tests/test_client_dispatch.py:79`
+
+The four dict-routing tests (async/sync, normal/reversed order) assert only output
+equality (`result == {"a": 1}`), but `PydanticDecoder` and `MsgspecDecoder` both decode
+`dict[str, int]` to an identical dict. The assertion passes regardless of which decoder
+handled the request, so the central ordering invariant of the epic — shared shapes route
+to the first decoder in the list — is never actually verified. A regression that always
+routed to the second decoder would pass all four tests.
+
+```python
+async def test_async_dict_routes_to_first_decoder() -> None:
+ """Shared shape: first decoder in the list wins."""
+ pyd = PydanticDecoder()
+ msg = MsgspecDecoder()
+ client = _async_client_with_body(b'{"a": 1}', decoders=[pyd, msg])
+ result = await client.get("https://example.test/x", response_model=dict[str, int])
+ assert type(result) is dict
+ assert result == {"a": 1}
+```
+
+Verifier consensus: 2/3 (code_reality + reproducer). Suggested direction: introduce a
+recording decoder pair (each appends its name on `decode`) and assert which one ran, so the
+ordering invariant is directly observed.
+
+### Low
+
+#### Dataclass routing test name overclaims; assertion can't tell pydantic from msgspec
+
+`tests/test_client_dispatch.py:99`
+
+`test_async_dataclass_routes_to_first_decoder` is named for order significance (both
+decoders claim a stdlib dataclass), but its only assertion is `type(result) is _DC`, which
+both `PydanticDecoder` and `MsgspecDecoder` satisfy. It does not prove the first decoder
+(pydantic) actually ran — the same wrong-reason pass as the dict tests.
+
+```python
+async def test_async_dataclass_routes_to_first_decoder() -> None:
+ client = _async_client_with_body(
+ b'{"id": 1, "name": "Ada"}',
+ decoders=[PydanticDecoder(), MsgspecDecoder()],
+ )
+ result = await client.get("https://example.test/x", response_model=_DC)
+ assert type(result) is _DC
+ assert result.id == 1
+```
+
+Verifier consensus: 2/3 (code_reality + reproducer). Suggested direction: fold into the
+recording-decoder fix above so the assertion proves decoder identity, not just result type.
+
+#### docs/index.md decoder dispatch code block uses PydanticDecoder/MsgspecDecoder without imports
+
+`docs/index.md:80`
+
+The "Decoder dispatch" section shows `AsyncClient(decoders=[PydanticDecoder(),
+MsgspecDecoder()])` with no import statement. Neither symbol is exported from the top-level
+`httpware` namespace — they live at `httpware.decoders.pydantic` and
+`httpware.decoders.msgspec`. A reader copying the snippet verbatim gets
+`NameError: name 'PydanticDecoder' is not defined`.
+
+```python
+# pydantic-first (the default when both extras are installed):
+# - BaseModel -> pydantic
+# - Struct -> msgspec
+# - dict, list -> pydantic (first in list)
+AsyncClient(decoders=[PydanticDecoder(), MsgspecDecoder()])
+```
+
+Verifier consensus: 2/3 (code_reality + reproducer). Suggested direction: add
+`from httpware.decoders.pydantic import PydanticDecoder` and
+`from httpware.decoders.msgspec import MsgspecDecoder` to the snippet.
+
+#### README.md quickstart typed-decoding note is pydantic-only after 0.9.0 added msgspec parity
+
+`README.md:53`
+
+Line 53 says typed decoding via `response_model=` "requires `pip install
+httpware[pydantic]`." After 0.9.0 msgspec also supports typed decoding via
+`response_model=`, and the auto-resolved default includes msgspec when pydantic is absent.
+A reader who has only msgspec installed will incorrectly conclude they must install
+pydantic. The installation section above line 53 was updated; this sentence was missed.
+
+```text
+Typed decoding via `response_model=` works in both worlds — requires `pip install httpware[pydantic]`. Decode failures (malformed body, schema mismatch) raise `httpware.DecodeError`, a `ClientError` subclass — so `except httpware.ClientError` covers them alongside transport and status errors.
+```
+
+Verifier consensus: 2/3 (spec_grounded + spec_grounded). Suggested direction: reword to
+state typed decoding works with either the `pydantic` or `msgspec` extra.
+
+### Nit
+
+#### MsgspecDecoder.can_decode makes an uncached type_info call on every dispatch
+
+`src/httpware/decoders/msgspec.py:48`
+
+`can_decode` runs on every `.send()` and always calls `msgspec.inspect.type_info(model)`
+(~6.5 µs) even for already-classified types, while `PydanticDecoder.can_decode` short-
+circuits via a cached `dict.get()` (~33 ns). The per-instance `_msgspec_decoders` cache
+already holds the result for known types and could short-circuit the probe.
+
+```python
+ def can_decode(self, model: type) -> bool:
+ try:
+ info = msgspec.inspect.type_info(model)
+ except Exception: # noqa: BLE001 — can_decode is a probe; any failure means no
+ return False
+```
+
+Verifier consensus: 2/3 (code_reality + code_reality). Suggested direction: check the
+per-instance cache before the `type_info` probe on the hot path.
+
+#### PydanticDecoder.can_decode does not cache failed probes
+
+`src/httpware/decoders/pydantic.py:50`
+
+When `can_decode` rejects a model (e.g. a `msgspec.Struct`), it calls `TypeAdapter(model)`,
+catches `PydanticSchemaGenerationError`, and returns `False` without storing anything. Every
+later `.send()` with that model repeats the ~0.03 ms construction instead of an O(1) dict
+lookup. Caching negative results (a sentinel in `_adapters`) would avoid it.
+
+```python
+ def can_decode(self, model: type) -> bool:
+ try:
+ self._get_adapter(model)
+ except Exception: # noqa: BLE001 — can_decode is a probe; any failure means no
+ return False
+ return True
+```
+
+Verifier consensus: 2/3 (code_reality + code_reality). Suggested direction: memoize
+negative probe results so rejected model types resolve to an O(1) lookup.
+
+#### can_decode() exceptions in custom decoders escape the DecodeError wrap boundary
+
+`src/httpware/client.py:171`
+
+`_dispatch_decoder` calls `can_decode()` on each registered decoder before the
+`try/except` that produces `DecodeError`. If a third-party `ResponseDecoder.can_decode()`
+raises, the exception escapes `send`/`send_with_response` unwrapped, so `except ClientError`
+does not catch it. Both built-in decoders swallow probe exceptions, so this is unreachable
+with the bundled adapters, but the protocol contract is silent on the obligation.
+
+```python
+ def _dispatch_decoder(self, model: type) -> ResponseDecoder | None:
+ """Walk `_decoders` and return the first decoder claiming `model`, or None."""
+ for decoder in self._decoders:
+ if decoder.can_decode(model):
+ return decoder
+ return None
+```
+
+Verifier consensus: 2/3 (code_reality + code_reality). Suggested direction: document the
+no-raise obligation for `can_decode`, or guard the dispatch loop so probe failures map to a
+`ClientError` subclass.
+
+#### CLAUDE.md exception-construction rule is ambiguously worded vs the codebase pattern
+
+`src/httpware/errors.py:290`
+
+`CLAUDE.md` says status-keyed errors take a single positional `response` and "Subclasses do
+not override `__init__`." The phrase is ambiguous about whether it scopes to all
+`ClientError` subclasses or only `StatusError` subclasses. In practice `DecodeError`,
+`BulkheadFullError`, `RetryBudgetExhaustedError`, and now `MissingDecoderError` all override
+`__init__` with keyword-only args. `engineering.md` §4 correctly scopes the rule to
+`StatusError` and its 4xx/5xx subclasses; `CLAUDE.md` does not.
+
+```python
+ def __init__(self, *, model: type, registered_names: tuple[str, ...]) -> None:
+ self.model = model
+ self.registered_names = registered_names
+ super().__init__(_missing_decoder_summary(model, registered_names))
+```
+
+Verifier consensus: 3/3 (spec_grounded ×3). Suggested direction: scope the `CLAUDE.md` rule
+to `StatusError` subclasses, mirroring `engineering.md` §4.
+
+#### Dataclass and list-of-model routing tested only on AsyncClient, no sync Client twin
+
+`tests/test_client_dispatch.py:99`
+
+The sync dispatch suite covers only struct and dict routing; dataclass routing
+(`test_async_dataclass_routes_to_first_decoder`) and list-of-`BaseModel` routing
+(`test_async_list_of_basemodel_routes_to_pydantic`) have async-only coverage. A sync-only
+dispatch regression in those two model shapes would go uncaught.
+
+```python
+async def test_async_dataclass_routes_to_first_decoder() -> None:
+ client = _async_client_with_body(
+ b'{"id": 1, "name": "Ada"}',
+ decoders=[PydanticDecoder(), MsgspecDecoder()],
+ )
+ result = await client.get("https://example.test/x", response_model=_DC)
+ assert type(result) is _DC
+ assert result.id == 1
+```
+
+Verifier consensus: 2/3 (code_reality + reproducer). Suggested direction: add sync `Client`
+twins for the dataclass and list-of-model routing tests.
+
+#### docs/errors.md MissingDecoderError hint text does not match the actual exception message
+
+`docs/errors.md:168`
+
+The doc shows two verbatim hint strings readers are expected to match. The first omits
+backticks present in the real message (`` `pip install httpware[pydantic]` ``). The second
+is truncated — the code produces `. Pass a custom decoder via decoders=[...].` but the doc
+ends at `... all rejected it.`. Code that string-matches these hints against `str(exc)`
+fails.
+
+```text
+- `no decoders registered. Install pip install httpware[pydantic] or pip install httpware[msgspec], or pass decoders=[...] explicitly.` — install an extra or pass an explicit decoder list.
+- `registered decoders (PydanticDecoder + MsgspecDecoder) all rejected it.` — your `response_model` type is exotic enough that neither built-in claims it. Pass a custom `ResponseDecoder` via `decoders=[...]`.
+```
+
+Verifier consensus: 2/3 (spec_grounded + reproducer). Suggested direction: copy the exact
+message strings from `errors.py` into the doc, including backticks and the trailing
+sentence.
+
+#### planning/deferred-work.md Open lru_cache entry is resolved by PR #42 but still listed as Open
+
+`planning/deferred-work.md:11`
+
+The Open section lists "`_get_adapter` `lru_cache` is module-global" at
+`src/httpware/decoders/pydantic.py:12-14`. PR #42 replaced the module-level
+`@functools.lru_cache` with per-instance `_adapters` / `_msgspec_decoders` dicts; lines
+12-14 no longer contain an `lru_cache`. The item is closed but still filed under Open, so an
+agent scanning deferred-work investigates a non-existent problem.
+
+```text
+- **`_get_adapter` `lru_cache` is module-global, not per-decoder instance** — keyed by `model` only; two `PydanticDecoder()` instances with different configurations (none today) would share adapters, and the cache survives across tests unless explicitly cleared. ... (`src/httpware/decoders/pydantic.py:12-14`)
+```
+
+Verifier consensus: 2/3 (code_reality + spec_grounded). Suggested direction: move the entry
+to a closed/resolved section noting PR #42, or delete it.
+
+#### planning/engineering.md §1 docs URL still points to stale httpware.readthedocs.io
+
+`planning/engineering.md:7`
+
+The §1 historical sentence "the first-cut user-docs surface is live at
+`https://httpware.readthedocs.io/`" was not updated when docs moved to GitHub Pages at
+`https://httpware.modern-python.org/`. The §8 Epic 6 entry was correctly updated by commit
+`3b02a41` but §1 was missed; an agent following the §1 URL reaches the stale RTD site.
+
+```text
+As of 0.7.0, the first-cut user-docs surface is live at (Middleware, Resilience, Errors, Testing guides) and Epic 3 is closed.
+```
+
+Verifier consensus: 2/3 (code_reality + spec_grounded). Suggested direction: update the §1
+URL to `https://httpware.modern-python.org/`.
+
+## Dimension coverage
+
+- **decoder_routing** — findings survived (1 High, 3 Nit).
+- **seam_parity** — findings survived (1 Medium, 1 Low, 1 Nit).
+- **new_tests** — covered under seam_parity / decoder_routing test findings above; no
+ additional new-tests-only findings survived verification beyond those listed.
+- **docs_consistency** — findings survived (1 High, 2 Low, 4 Nit).
+
+## Recategorization notes
+
+No docs_consistency finding was moved to a code dimension: every docs_consistency finding's
+verifier reasons place the fix in the DOC (`CLAUDE.md` Seam B, `docs/index.md` imports,
+`README.md` note, `CLAUDE.md` exception rule, `docs/errors.md` hints, `deferred-work.md`
+entry, `engineering.md` §1 URL). The `CLAUDE.md` Seam B item is High because it misleads a
+reasonable reader into shipping a broken interface, but the corrective edit is still to the
+document, so it stays in docs_consistency.
+
+## Dropped as duplicates
+
+None. Each of the 14 confirmed findings carries new 0.9.0-delta evidence not recorded in
+[`planning/audit/2026-06-07-deep-audit.md`](2026-06-07-deep-audit.md) or
+[`planning/deferred-work.md`](../deferred-work.md). The `deferred-work.md` lru_cache entry
+appears here only as the *subject* of a finding (new evidence that PR #42 resolved it), not
+as a restatement of a still-open item.
diff --git a/planning/audit/workflow-delta.mjs b/planning/audit/workflow-delta.mjs
new file mode 100644
index 0000000..bdb592a
--- /dev/null
+++ b/planning/audit/workflow-delta.mjs
@@ -0,0 +1,427 @@
+export const meta = {
+ name: 'httpware-delta-audit',
+ description: 'Delta audit of the 0.9.0 multi-decoder epic (find + verify + synthesize, single chunk)',
+ phases: [
+ { title: 'Find', detail: 'One finder per dimension (4)' },
+ { title: 'Verify', detail: '3-voter panel per finding' },
+ { title: 'Synthesize', detail: 'Triage and write the audit doc' },
+ ],
+}
+
+// ───── Constants ────────────────────────────────────────────────────────────
+
+const AUDIT_FILE = 'planning/audit/2026-06-12-delta-audit.md'
+const PRIOR_AUDIT = 'planning/audit/2026-06-07-deep-audit.md'
+const BASELINE = '0.8.6'
+
+const DELTA_PREAMBLE = `Baseline context: everything up to tag ${BASELINE} was deep-audited on
+2026-06-07 and all 35 findings were closed by release 0.8.6 (see ${PRIOR_AUDIT}).
+You are auditing ONLY the delta since then: the 0.9.0 multi-decoder routing epic
+(PRs #41, #42). Do NOT re-report items already recorded (closed or deferred) in
+${PRIOR_AUDIT} or planning/deferred-work.md unless you have genuinely new evidence.
+
+For change context on any in-scope file, run: git diff ${BASELINE}..HEAD --
+Read the current file contents too — the diff alone lacks surrounding context.`
+
+// ───── Schemas (unchanged from workflow.mjs) ────────────────────────────────
+
+const FINDING_SCHEMA = {
+ type: 'object',
+ required: ['findings'],
+ properties: {
+ findings: {
+ type: 'array',
+ items: {
+ type: 'object',
+ required: ['dimension', 'title', 'file', 'line_hint', 'claim',
+ 'evidence_quote', 'suspected_severity'],
+ properties: {
+ dimension: { type: 'string' },
+ title: { type: 'string' },
+ file: { type: 'string' },
+ line_hint: { type: 'integer' },
+ claim: { type: 'string' },
+ evidence_quote: { type: 'string' },
+ suspected_severity: { enum: ['blocker', 'high', 'medium', 'low', 'nit'] },
+ reproducer_hint: { type: ['string', 'null'] },
+ },
+ },
+ },
+ },
+}
+
+const VERDICT_SCHEMA = {
+ type: 'object',
+ required: ['lens', 'confirmed', 'reason'],
+ properties: {
+ lens: { enum: ['code_reality', 'reproducer', 'spec_grounded'] },
+ confirmed: { type: 'boolean' },
+ reason: { type: 'string' },
+ quoted_evidence: { type: ['string', 'null'] },
+ severity_adjustment: { enum: ['unchanged', 'raise', 'lower', null] },
+ },
+}
+
+// ───── Dimension prompts ────────────────────────────────────────────────────
+
+const DIMENSION_PROMPTS = {
+ decoder_routing: `You are auditing the httpware 0.9.0 delta for DECODER ROUTING
+correctness only.
+
+${DELTA_PREAMBLE}
+
+Files in scope:
+- src/httpware/client.py (decoders=[...] parameter, type-dispatched routing,
+ _build_default_decoders, MissingDecoderError pre-flight)
+- src/httpware/decoders/__init__.py
+- src/httpware/decoders/pydantic.py (can_decode, per-instance adapter cache)
+- src/httpware/decoders/msgspec.py (can_decode, per-instance decoder cache)
+- src/httpware/errors.py (MissingDecoderError)
+- src/httpware/__init__.py (new exports)
+
+Look for:
+- can_decode first-match dispatch: is the order deterministic, and can an
+ earlier decoder incorrectly shadow a later one for a model type both accept?
+- MissingDecoderError pre-flight timing: it must raise BEFORE the request is
+ sent when response_model matches no decoder. Verify where the check sits
+ relative to the middleware chain / terminal send.
+- _build_default_decoders resolution matrix: correct result for each
+ installed-extras combination (neither, pydantic only, msgspec only, both),
+ and a sensible deterministic order when both are installed.
+- Per-instance caches (PR #42): no residual shared module state, no
+ cross-instance leakage, unbounded-growth behavior on many model types.
+- The msgspec CustomType trap: msgspec.json.Decoder(SomePydanticModel)
+ SUCCEEDS via CustomType fallback rather than raising. can_decode must use
+ msgspec.inspect.type_info with a CustomType filter, not try/except around
+ Decoder construction. Verify the implementation avoids the trap for
+ pydantic BaseModel subclasses AND other CustomType-falling types.
+
+Out of scope: sync/async parity and Seam B contract (another finder covers
+those), test quality, docs accuracy.
+
+For each finding return: title, file, approximate line, a 1-3 sentence claim
+explaining what is wrong AND why it is wrong, a verbatim 5-15 line evidence
+quote, suspected severity, and a reproducer hint if applicable.
+
+Default to NOT reporting when uncertain. Quality > quantity. 4-10 findings
+target — fewer is fine if the code is clean.`,
+
+ seam_parity: `You are auditing the httpware 0.9.0 delta for SEAM B CONTRACT
+conformance and SYNC/ASYNC PARITY only.
+
+${DELTA_PREAMBLE}
+
+Files in scope:
+- src/httpware/client.py — compare Client.send vs AsyncClient.send, and
+ Client.send_with_response vs AsyncClient.send_with_response
+- src/httpware/errors.py (MissingDecoderError, DecodeError)
+- planning/engineering.md §Seam B and CLAUDE.md (the documented contracts)
+
+Look for:
+- Both send implementations must invoke decoder routing IDENTICALLY (same
+ dispatch, same pre-flight, same error wrapping). Diff them side by side.
+- send_with_response (both sides) must route through the same dispatch as
+ send — no copy-paste divergence.
+- DecodeError must still wrap decoder failures at Seam B (the 0.8.1
+ contract): a decoder raising inside decode() must surface as DecodeError,
+ catchable via except ClientError.
+- MissingDecoderError vs the CLAUDE.md exception conventions: status-keyed
+ errors take a single positional response and subclasses do not override
+ __init__. MissingDecoderError is NOT status-keyed, so deviation may be
+ deliberate — check what errors.py docstrings and engineering.md actually
+ claim, and report only contradictions between code and documented contract.
+
+Out of scope: dispatch-logic bugs within a single implementation (the
+decoder_routing finder covers those), test quality, docs accuracy.
+
+For each finding return: title, file, approximate line, a 1-3 sentence claim,
+a verbatim 5-15 line evidence quote, suspected severity, reproducer hint.
+
+Default to NOT reporting when uncertain. 3-8 findings target.`,
+
+ new_tests: `You are auditing the QUALITY of the test code added or changed in
+the httpware 0.9.0 delta. Production-code bugs are out of scope — only the
+tests themselves.
+
+${DELTA_PREAMBLE}
+
+Files in scope (only these; pre-existing untouched test files are out of scope):
+- tests/test_client_construction.py
+- tests/test_client_decoders_default.py
+- tests/test_client_dispatch.py
+- tests/test_client_send_with_response.py
+- tests/test_client_send_with_response_sync.py
+- tests/test_client_sync.py
+- tests/test_decoders_msgspec.py
+- tests/test_decoders_pydantic.py
+- tests/test_errors.py
+- tests/test_optional_extras_pydantic_missing.py
+- tests/test_public_api.py
+
+Look for, in priority order:
+- Tests that pass for the wrong reason: tautological asserts (a known
+ reviewer blind spot in this repo), assertions on mock behavior rather than
+ subject behavior, exception asserts that would also pass on the wrong
+ exception type.
+- Dispatch-matrix coverage gaps: decoder order significance, overlapping
+ can_decode acceptance, a model type matching NO decoder, empty decoders
+ list, explicit decoders vs default resolution.
+- Sync/async parity gaps: a behavior tested on AsyncClient with no Client
+ twin, or vice versa.
+- MockTransport bytes that real httpx2 servers would never produce, hiding
+ real decode behavior.
+
+Conventions to respect (not findings): pytest-asyncio auto mode means async
+tests need no marker; tests inject httpx2.MockTransport via
+Client(httpx2_client=httpx2.Client(transport=mock)) — that pattern itself is
+correct and documented.
+
+For each finding return: title, file, approximate line, a 1-3 sentence claim,
+a verbatim 5-15 line evidence quote, suspected severity, reproducer hint.
+
+Default to NOT reporting when uncertain. Do not report style opinions.
+4-10 findings target — fewer is fine.`,
+
+ docs_consistency: `You are auditing the ENTIRE httpware documentation surface
+for consistency with post-0.9.0 reality. The 0.9.0 release changed the decoder
+story (decoder= became decoders=[...], type-dispatched via can_decode) and
+REVERSED the 0.3.0 fail-fast behavior (missing-extra errors moved from
+Client.__init__ to a MissingDecoderError pre-flight at send time). Unchanged
+pages may therefore be stale — read them all.
+
+${DELTA_PREAMBLE}
+
+Files in scope (full sweep):
+- docs/index.md, docs/errors.md, docs/middleware.md, docs/resilience.md,
+ docs/testing.md
+- docs/recipes/*.md, docs/dev/*.md
+- README.md
+- CLAUDE.md (module layout, Seam B description, exception contract)
+- planning/engineering.md
+- planning/deferred-work.md (items quietly resolved by 0.9.0 — e.g. the
+ module-global lru_cache item looks closed by PR #42 but may still be
+ listed as Open)
+- planning/releases/0.9.0.md (claims vs actual code)
+- mkdocs.yml nav vs files that exist on disk
+
+Verify:
+- Every code block imports resolve against the current public API and would
+ actually run. Watch for residual singular decoder= usage anywhere.
+- Every load-bearing prose claim about decoder behavior matches the code:
+ fail-fast timing, default-decoder resolution, multi-decoder coexistence.
+- Internal links resolve; mkdocs nav matches files on disk.
+
+When you report a finding, state in the claim whether the fix belongs in the
+DOC or in the CODE (i.e. the doc describes intended behavior the code fails
+to deliver).
+
+Out of scope per the project docs philosophy: absence of migration guides,
+API autodoc, or benchmarks is NOT a finding; prose style and doc structure
+opinions are NOT findings.
+
+For each finding return: title, file, approximate line, a 1-3 sentence claim,
+a verbatim 5-15 line evidence quote, suspected severity, reproducer hint
+(for docs: the misleading-reading scenario).
+
+Default to NOT reporting when uncertain. 5-12 findings target.`,
+}
+
+// ───── Verifier prompts ─────────────────────────────────────────────────────
+
+const VERIFIER_PROMPTS = {
+ code_reality: (f) => `Re-read ${f.file} around line ${f.line_hint} (±30 lines).
+The finder claims:
+
+Title: ${f.title}
+Claim: ${f.claim}
+Evidence quoted by finder:
+${f.evidence_quote}
+
+Does the claim match what the code/doc actually says, or did the finder
+misread? Default to confirmed: false if the cited content does not support
+the claim, or if you can't locate it.
+
+If this is a docs finding, also state in your reason whether the fix belongs
+in the DOC or in the CODE.
+
+Return your verdict per schema.`,
+
+ reproducer: (f) => `The finder claims:
+
+Title: ${f.title}
+Claim: ${f.claim}
+Reproducer hint: ${f.reproducer_hint ?? '(none provided)'}
+
+Could you sketch a test (3-5 lines) that demonstrates this bug? If the finding
+is in docs or planning docs, reframe: would a reader making a reasonable choice
+based on the doc be misled?
+
+If you cannot construct a reproducer (or a misleading-reading), set
+confirmed: false. Otherwise confirmed: true with the sketch in quoted_evidence.`,
+
+ spec_grounded: (f) => `The finder claims:
+
+Title: ${f.title}
+Claim: ${f.claim}
+
+Two checks:
+
+1. Does this violate a stated invariant in CLAUDE.md or planning/engineering.md
+ (error contract, Seam B decode contract, optional-extras pattern, no
+ httpx2._ private API, no global logging config, naming conventions, etc.)?
+ - If yes: confirmed: true, cite the invariant verbatim in quoted_evidence.
+ - If it's a judgment call with no spec backing: confirmed: false.
+
+2. Is this a re-report of an item already recorded in
+ planning/audit/2026-06-07-deep-audit.md (closed by 0.8.6) or
+ planning/deferred-work.md, with no new evidence? If so: confirmed: false
+ and say which existing item it duplicates.
+
+Severity adjustment: raise if this violates a CLAUDE.md-listed invariant;
+lower if the spec is silent and this is a hardening suggestion.`,
+}
+
+// ───── Synthesis prompt ─────────────────────────────────────────────────────
+
+const SYNTHESIS_PROMPT = (dims, confirmed) => `
+You are synthesizing the httpware 0.9.0 delta audit (baseline ${BASELINE} -> HEAD).
+
+You have ${confirmed.length} confirmed findings across dimensions: ${dims.join(', ')}.
+
+HARD CONSTRAINT: you may create or edit ONLY ${AUDIT_FILE}. Do not create,
+edit, or delete any other file. Do not fix any finding.
+
+Tasks:
+1. Read ${PRIOR_AUDIT} and planning/deferred-work.md. Drop any confirmed
+ finding that merely restates an item recorded there (closed or deferred)
+ without new evidence; note dropped duplicates in a short "Dropped as
+ duplicates" list at the end of the file.
+2. Recategorize: a docs_consistency finding whose verifier reasons say the
+ fix belongs in CODE moves to the appropriate code dimension
+ (decoder_routing or seam_parity) with a note.
+3. Triage each finding into buckets using these definitions, strictly:
+ - Blocker: wrong-correctness bug affecting users in normal usage; a
+ documented invariant violated; a doc example that does not run.
+ - High: bug behind a non-default path; missing safety check at a
+ documented boundary; docs that mislead a reasonable reader.
+ - Medium: works today but relies on undocumented invariants;
+ accurate-but-ambiguous docs; test gaps in load-bearing primitives.
+ - Low: minor inaccuracies, weak idioms, hardening suggestions.
+ - Nit: style, naming, punctuation. If more than 4 nits surface in one
+ dimension, collapse them into a single rolled-up entry.
+4. Create ${AUDIT_FILE} with:
+ - Header: title "httpware delta audit — 2026-06-12 (0.9.0 multi-decoder
+ epic)", status complete, baseline ${BASELINE} -> current HEAD short SHA
+ (run: git rev-parse --short HEAD), links to
+ planning/specs/2026-06-12-delta-audit-design.md and
+ planning/plans/2026-06-12-delta-audit-plan.md.
+ - "## Summary": bucket counts (Blockers/High/Medium/Low/Nits) and a
+ one-paragraph headline naming the most severe finding.
+ - "## Findings" grouped by bucket (highest first). Each finding: title,
+ file:line in code format, claim (3 sentences max), evidence quote in a
+ fenced code block, verifier consensus (2/3 or 3/3 + which lenses
+ confirmed), suggested direction (one sentence).
+ - For any dimension with zero surviving findings, an explicit
+ "no findings survived verification" line.
+5. Commit exactly one file:
+ git add ${AUDIT_FILE}
+ git commit -m "audit(delta): 0.9.0 multi-decoder delta audit findings"
+ Then run git status and confirm the tree is clean apart from untracked
+ files that existed before your run.
+
+Confirmed findings JSON:
+${JSON.stringify(confirmed, null, 2)}
+`
+
+// ───── Script body ──────────────────────────────────────────────────────────
+
+const SONNET = 'claude-sonnet-4-6'
+const OPUS = 'claude-opus-4-8'
+
+const FINDER_MODELS = {
+ decoder_routing: SONNET,
+ seam_parity: SONNET,
+ new_tests: OPUS, // Sonnet stalled twice on meta-review dimensions in the deep audit
+ docs_consistency: SONNET,
+}
+
+// args may arrive as a JSON string (depending on harness) — normalize.
+const cfg = typeof args === 'string' ? JSON.parse(args) : (args ?? {})
+const dims = cfg.dimensions ?? Object.keys(DIMENSION_PROMPTS)
+const unknownDims = dims.filter(d => !DIMENSION_PROMPTS[d])
+if (unknownDims.length) throw new Error(`Unknown dimensions: ${unknownDims.join(', ')}`)
+
+phase('Find')
+const findings = await parallel(
+ dims.map(dim => () =>
+ agent(`${DIMENSION_PROMPTS[dim]}\n\nReturn per schema.`, {
+ model: FINDER_MODELS[dim], schema: FINDING_SCHEMA,
+ label: `find:${dim}`, phase: 'Find',
+ })
+ ),
+)
+
+const FINDINGS_PER_DIM_CAP = 15
+const rawDimensionResults = findings.filter(Boolean)
+for (const r of rawDimensionResults) {
+ if (r.findings.length > FINDINGS_PER_DIM_CAP) {
+ const dimName = r.findings[0]?.dimension ?? ''
+ log(`WARNING: dimension ${dimName} returned ${r.findings.length} findings; capping at ${FINDINGS_PER_DIM_CAP}`)
+ }
+}
+const allFindings = rawDimensionResults.flatMap(r => r.findings.slice(0, FINDINGS_PER_DIM_CAP))
+log(`Found ${allFindings.length} candidate findings across ${dims.length} dimensions`)
+
+phase('Verify')
+const verified = await parallel(
+ allFindings.map(f => () =>
+ parallel(['code_reality', 'reproducer', 'spec_grounded'].map(lens => () =>
+ agent(VERIFIER_PROMPTS[lens](f), {
+ model: SONNET, schema: VERDICT_SCHEMA,
+ label: `verify:${f.dimension}:${lens}`, phase: 'Verify',
+ })
+ )).then(verdicts => {
+ const live = verdicts.filter(Boolean)
+ const confirms = live.filter(v => v.confirmed).length
+ const lensesConfirming = live.filter(v => v.confirmed).map(v => v.lens)
+ const adjustments = live.map(v => v.severity_adjustment).filter(Boolean)
+ let severity = f.suspected_severity
+ if (adjustments.filter(a => a === 'lower').length >= 1) severity = lowerOne(severity)
+ if (adjustments.filter(a => a === 'raise').length >= 2) severity = raiseOne(severity)
+ if (live.length === 0) {
+ log(`WARNING: all 3 verifiers failed for finding "${f.title}" (${f.file}:${f.line_hint}) — dropped`)
+ }
+ return confirms >= 2 ? { ...f, final_severity: severity, lensesConfirming } : null
+ })
+ ),
+)
+
+const confirmed = verified.filter(Boolean)
+log(`${confirmed.length}/${allFindings.length} findings confirmed by ≥2 verifiers`)
+
+phase('Synthesize')
+await agent(SYNTHESIS_PROMPT(dims, confirmed), { model: OPUS, label: 'synthesize' })
+
+return {
+ candidates: allFindings.length,
+ confirmed: confirmed.length,
+ by_severity: countBySeverity(confirmed),
+}
+
+// ───── Helpers ──────────────────────────────────────────────────────────────
+
+function lowerOne(s) {
+ const order = ['nit', 'low', 'medium', 'high', 'blocker']
+ const i = order.indexOf(s)
+ return i > 0 ? order[i - 1] : s
+}
+function raiseOne(s) {
+ const order = ['nit', 'low', 'medium', 'high', 'blocker']
+ const i = order.indexOf(s)
+ return i < order.length - 1 ? order[i + 1] : s
+}
+function countBySeverity(arr) {
+ const out = { blocker: 0, high: 0, medium: 0, low: 0, nit: 0 }
+ for (const f of arr) out[f.final_severity ?? f.suspected_severity]++
+ return out
+}
diff --git a/planning/plans/2026-06-12-delta-audit-plan.md b/planning/plans/2026-06-12-delta-audit-plan.md
new file mode 100644
index 0000000..3b984cd
--- /dev/null
+++ b/planning/plans/2026-06-12-delta-audit-plan.md
@@ -0,0 +1,533 @@
+# 0.9.0 Delta Audit Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Task 2 must run inline in the main session** — the Workflow tool is a main-session orchestration tool and is not available to dispatched subagents.
+
+**Goal:** Run a verified, severity-bucketed audit of everything changed since tag `0.8.6` (the 0.9.0 multi-decoder epic) plus a full-site docs consistency sweep, producing `planning/audit/2026-06-12-delta-audit.md`.
+
+**Architecture:** Single-chunk adaptation of the proven deep-audit pipeline (`planning/audit/workflow.mjs`): 4 parallel finder agents with delta-scoped prompts → 3-voter verification panel per finding (≥2 consensus survives) → one synthesis agent that writes and commits the audit doc. No discover phase; file lists are inlined in prompts.
+
+**Tech Stack:** Claude Code Workflow tool (`scriptPath` invocation), models `claude-sonnet-4-6` (finders 1/2/4, verifiers) and `claude-opus-4-8` (`new_tests` finder, synthesis).
+
+**Spec:** [planning/specs/2026-06-12-delta-audit-design.md](../specs/2026-06-12-delta-audit-design.md)
+
+---
+
+### Task 1: Author the delta workflow script
+
+**Files:**
+- Create: `planning/audit/workflow-delta.mjs`
+
+- [ ] **Step 1: Write the script**
+
+Create `planning/audit/workflow-delta.mjs` with exactly this content:
+
+```js
+export const meta = {
+ name: 'httpware-delta-audit',
+ description: 'Delta audit of the 0.9.0 multi-decoder epic (find + verify + synthesize, single chunk)',
+ phases: [
+ { title: 'Find', detail: 'One finder per dimension (4)' },
+ { title: 'Verify', detail: '3-voter panel per finding' },
+ { title: 'Synthesize', detail: 'Triage and write the audit doc' },
+ ],
+}
+
+// ───── Constants ────────────────────────────────────────────────────────────
+
+const AUDIT_FILE = 'planning/audit/2026-06-12-delta-audit.md'
+const PRIOR_AUDIT = 'planning/audit/2026-06-07-deep-audit.md'
+const BASELINE = '0.8.6'
+
+const DELTA_PREAMBLE = `Baseline context: everything up to tag ${BASELINE} was deep-audited on
+2026-06-07 and all 35 findings were closed by release 0.8.6 (see ${PRIOR_AUDIT}).
+You are auditing ONLY the delta since then: the 0.9.0 multi-decoder routing epic
+(PRs #41, #42). Do NOT re-report items already recorded (closed or deferred) in
+${PRIOR_AUDIT} or planning/deferred-work.md unless you have genuinely new evidence.
+
+For change context on any in-scope file, run: git diff ${BASELINE}..HEAD --
+Read the current file contents too — the diff alone lacks surrounding context.`
+
+// ───── Schemas (unchanged from workflow.mjs) ────────────────────────────────
+
+const FINDING_SCHEMA = {
+ type: 'object',
+ required: ['findings'],
+ properties: {
+ findings: {
+ type: 'array',
+ items: {
+ type: 'object',
+ required: ['dimension', 'title', 'file', 'line_hint', 'claim',
+ 'evidence_quote', 'suspected_severity'],
+ properties: {
+ dimension: { type: 'string' },
+ title: { type: 'string' },
+ file: { type: 'string' },
+ line_hint: { type: 'integer' },
+ claim: { type: 'string' },
+ evidence_quote: { type: 'string' },
+ suspected_severity: { enum: ['blocker', 'high', 'medium', 'low', 'nit'] },
+ reproducer_hint: { type: ['string', 'null'] },
+ },
+ },
+ },
+ },
+}
+
+const VERDICT_SCHEMA = {
+ type: 'object',
+ required: ['lens', 'confirmed', 'reason'],
+ properties: {
+ lens: { enum: ['code_reality', 'reproducer', 'spec_grounded'] },
+ confirmed: { type: 'boolean' },
+ reason: { type: 'string' },
+ quoted_evidence: { type: ['string', 'null'] },
+ severity_adjustment: { enum: ['unchanged', 'raise', 'lower', null] },
+ },
+}
+
+// ───── Dimension prompts ────────────────────────────────────────────────────
+
+const DIMENSION_PROMPTS = {
+ decoder_routing: `You are auditing the httpware 0.9.0 delta for DECODER ROUTING
+correctness only.
+
+${DELTA_PREAMBLE}
+
+Files in scope:
+- src/httpware/client.py (decoders=[...] parameter, type-dispatched routing,
+ _build_default_decoders, MissingDecoderError pre-flight)
+- src/httpware/decoders/__init__.py
+- src/httpware/decoders/pydantic.py (can_decode, per-instance adapter cache)
+- src/httpware/decoders/msgspec.py (can_decode, per-instance decoder cache)
+- src/httpware/errors.py (MissingDecoderError)
+- src/httpware/__init__.py (new exports)
+
+Look for:
+- can_decode first-match dispatch: is the order deterministic, and can an
+ earlier decoder incorrectly shadow a later one for a model type both accept?
+- MissingDecoderError pre-flight timing: it must raise BEFORE the request is
+ sent when response_model matches no decoder. Verify where the check sits
+ relative to the middleware chain / terminal send.
+- _build_default_decoders resolution matrix: correct result for each
+ installed-extras combination (neither, pydantic only, msgspec only, both),
+ and a sensible deterministic order when both are installed.
+- Per-instance caches (PR #42): no residual shared module state, no
+ cross-instance leakage, unbounded-growth behavior on many model types.
+- The msgspec CustomType trap: msgspec.json.Decoder(SomePydanticModel)
+ SUCCEEDS via CustomType fallback rather than raising. can_decode must use
+ msgspec.inspect.type_info with a CustomType filter, not try/except around
+ Decoder construction. Verify the implementation avoids the trap for
+ pydantic BaseModel subclasses AND other CustomType-falling types.
+
+Out of scope: sync/async parity and Seam B contract (another finder covers
+those), test quality, docs accuracy.
+
+For each finding return: title, file, approximate line, a 1-3 sentence claim
+explaining what is wrong AND why it is wrong, a verbatim 5-15 line evidence
+quote, suspected severity, and a reproducer hint if applicable.
+
+Default to NOT reporting when uncertain. Quality > quantity. 4-10 findings
+target — fewer is fine if the code is clean.`,
+
+ seam_parity: `You are auditing the httpware 0.9.0 delta for SEAM B CONTRACT
+conformance and SYNC/ASYNC PARITY only.
+
+${DELTA_PREAMBLE}
+
+Files in scope:
+- src/httpware/client.py — compare Client.send vs AsyncClient.send, and
+ Client.send_with_response vs AsyncClient.send_with_response
+- src/httpware/errors.py (MissingDecoderError, DecodeError)
+- planning/engineering.md §Seam B and CLAUDE.md (the documented contracts)
+
+Look for:
+- Both send implementations must invoke decoder routing IDENTICALLY (same
+ dispatch, same pre-flight, same error wrapping). Diff them side by side.
+- send_with_response (both sides) must route through the same dispatch as
+ send — no copy-paste divergence.
+- DecodeError must still wrap decoder failures at Seam B (the 0.8.1
+ contract): a decoder raising inside decode() must surface as DecodeError,
+ catchable via except ClientError.
+- MissingDecoderError vs the CLAUDE.md exception conventions: status-keyed
+ errors take a single positional response and subclasses do not override
+ __init__. MissingDecoderError is NOT status-keyed, so deviation may be
+ deliberate — check what errors.py docstrings and engineering.md actually
+ claim, and report only contradictions between code and documented contract.
+
+Out of scope: dispatch-logic bugs within a single implementation (the
+decoder_routing finder covers those), test quality, docs accuracy.
+
+For each finding return: title, file, approximate line, a 1-3 sentence claim,
+a verbatim 5-15 line evidence quote, suspected severity, reproducer hint.
+
+Default to NOT reporting when uncertain. 3-8 findings target.`,
+
+ new_tests: `You are auditing the QUALITY of the test code added or changed in
+the httpware 0.9.0 delta. Production-code bugs are out of scope — only the
+tests themselves.
+
+${DELTA_PREAMBLE}
+
+Files in scope (only these; pre-existing untouched test files are out of scope):
+- tests/test_client_construction.py
+- tests/test_client_decoders_default.py
+- tests/test_client_dispatch.py
+- tests/test_client_send_with_response.py
+- tests/test_client_send_with_response_sync.py
+- tests/test_client_sync.py
+- tests/test_decoders_msgspec.py
+- tests/test_decoders_pydantic.py
+- tests/test_errors.py
+- tests/test_optional_extras_pydantic_missing.py
+- tests/test_public_api.py
+
+Look for, in priority order:
+- Tests that pass for the wrong reason: tautological asserts (a known
+ reviewer blind spot in this repo), assertions on mock behavior rather than
+ subject behavior, exception asserts that would also pass on the wrong
+ exception type.
+- Dispatch-matrix coverage gaps: decoder order significance, overlapping
+ can_decode acceptance, a model type matching NO decoder, empty decoders
+ list, explicit decoders vs default resolution.
+- Sync/async parity gaps: a behavior tested on AsyncClient with no Client
+ twin, or vice versa.
+- MockTransport bytes that real httpx2 servers would never produce, hiding
+ real decode behavior.
+
+Conventions to respect (not findings): pytest-asyncio auto mode means async
+tests need no marker; tests inject httpx2.MockTransport via
+Client(httpx2_client=httpx2.Client(transport=mock)) — that pattern itself is
+correct and documented.
+
+For each finding return: title, file, approximate line, a 1-3 sentence claim,
+a verbatim 5-15 line evidence quote, suspected severity, reproducer hint.
+
+Default to NOT reporting when uncertain. Do not report style opinions.
+4-10 findings target — fewer is fine.`,
+
+ docs_consistency: `You are auditing the ENTIRE httpware documentation surface
+for consistency with post-0.9.0 reality. The 0.9.0 release changed the decoder
+story (decoder= became decoders=[...], type-dispatched via can_decode) and
+REVERSED the 0.3.0 fail-fast behavior (missing-extra errors moved from
+Client.__init__ to a MissingDecoderError pre-flight at send time). Unchanged
+pages may therefore be stale — read them all.
+
+${DELTA_PREAMBLE}
+
+Files in scope (full sweep):
+- docs/index.md, docs/errors.md, docs/middleware.md, docs/resilience.md,
+ docs/testing.md
+- docs/recipes/*.md, docs/dev/*.md
+- README.md
+- CLAUDE.md (module layout, Seam B description, exception contract)
+- planning/engineering.md
+- planning/deferred-work.md (items quietly resolved by 0.9.0 — e.g. the
+ module-global lru_cache item looks closed by PR #42 but may still be
+ listed as Open)
+- planning/releases/0.9.0.md (claims vs actual code)
+- mkdocs.yml nav vs files that exist on disk
+
+Verify:
+- Every code block imports resolve against the current public API and would
+ actually run. Watch for residual singular decoder= usage anywhere.
+- Every load-bearing prose claim about decoder behavior matches the code:
+ fail-fast timing, default-decoder resolution, multi-decoder coexistence.
+- Internal links resolve; mkdocs nav matches files on disk.
+
+When you report a finding, state in the claim whether the fix belongs in the
+DOC or in the CODE (i.e. the doc describes intended behavior the code fails
+to deliver).
+
+Out of scope per the project docs philosophy: absence of migration guides,
+API autodoc, or benchmarks is NOT a finding; prose style and doc structure
+opinions are NOT findings.
+
+For each finding return: title, file, approximate line, a 1-3 sentence claim,
+a verbatim 5-15 line evidence quote, suspected severity, reproducer hint
+(for docs: the misleading-reading scenario).
+
+Default to NOT reporting when uncertain. 5-12 findings target.`,
+}
+
+// ───── Verifier prompts ─────────────────────────────────────────────────────
+
+const VERIFIER_PROMPTS = {
+ code_reality: (f) => `Re-read ${f.file} around line ${f.line_hint} (±30 lines).
+The finder claims:
+
+Title: ${f.title}
+Claim: ${f.claim}
+Evidence quoted by finder:
+${f.evidence_quote}
+
+Does the claim match what the code/doc actually says, or did the finder
+misread? Default to confirmed: false if the cited content does not support
+the claim, or if you can't locate it.
+
+If this is a docs finding, also state in your reason whether the fix belongs
+in the DOC or in the CODE.
+
+Return your verdict per schema.`,
+
+ reproducer: (f) => `The finder claims:
+
+Title: ${f.title}
+Claim: ${f.claim}
+Reproducer hint: ${f.reproducer_hint ?? '(none provided)'}
+
+Could you sketch a test (3-5 lines) that demonstrates this bug? If the finding
+is in docs or planning docs, reframe: would a reader making a reasonable choice
+based on the doc be misled?
+
+If you cannot construct a reproducer (or a misleading-reading), set
+confirmed: false. Otherwise confirmed: true with the sketch in quoted_evidence.`,
+
+ spec_grounded: (f) => `The finder claims:
+
+Title: ${f.title}
+Claim: ${f.claim}
+
+Two checks:
+
+1. Does this violate a stated invariant in CLAUDE.md or planning/engineering.md
+ (error contract, Seam B decode contract, optional-extras pattern, no
+ httpx2._ private API, no global logging config, naming conventions, etc.)?
+ - If yes: confirmed: true, cite the invariant verbatim in quoted_evidence.
+ - If it's a judgment call with no spec backing: confirmed: false.
+
+2. Is this a re-report of an item already recorded in
+ planning/audit/2026-06-07-deep-audit.md (closed by 0.8.6) or
+ planning/deferred-work.md, with no new evidence? If so: confirmed: false
+ and say which existing item it duplicates.
+
+Severity adjustment: raise if this violates a CLAUDE.md-listed invariant;
+lower if the spec is silent and this is a hardening suggestion.`,
+}
+
+// ───── Synthesis prompt ─────────────────────────────────────────────────────
+
+const SYNTHESIS_PROMPT = (dims, confirmed) => `
+You are synthesizing the httpware 0.9.0 delta audit (baseline ${BASELINE} -> HEAD).
+
+You have ${confirmed.length} confirmed findings across dimensions: ${dims.join(', ')}.
+
+HARD CONSTRAINT: you may create or edit ONLY ${AUDIT_FILE}. Do not create,
+edit, or delete any other file. Do not fix any finding.
+
+Tasks:
+1. Read ${PRIOR_AUDIT} and planning/deferred-work.md. Drop any confirmed
+ finding that merely restates an item recorded there (closed or deferred)
+ without new evidence; note dropped duplicates in a short "Dropped as
+ duplicates" list at the end of the file.
+2. Recategorize: a docs_consistency finding whose verifier reasons say the
+ fix belongs in CODE moves to the appropriate code dimension
+ (decoder_routing or seam_parity) with a note.
+3. Triage each finding into buckets using these definitions, strictly:
+ - Blocker: wrong-correctness bug affecting users in normal usage; a
+ documented invariant violated; a doc example that does not run.
+ - High: bug behind a non-default path; missing safety check at a
+ documented boundary; docs that mislead a reasonable reader.
+ - Medium: works today but relies on undocumented invariants;
+ accurate-but-ambiguous docs; test gaps in load-bearing primitives.
+ - Low: minor inaccuracies, weak idioms, hardening suggestions.
+ - Nit: style, naming, punctuation. If more than 4 nits surface in one
+ dimension, collapse them into a single rolled-up entry.
+4. Create ${AUDIT_FILE} with:
+ - Header: title "httpware delta audit — 2026-06-12 (0.9.0 multi-decoder
+ epic)", status complete, baseline ${BASELINE} -> current HEAD short SHA
+ (run: git rev-parse --short HEAD), links to
+ planning/specs/2026-06-12-delta-audit-design.md and
+ planning/plans/2026-06-12-delta-audit-plan.md.
+ - "## Summary": bucket counts (Blockers/High/Medium/Low/Nits) and a
+ one-paragraph headline naming the most severe finding.
+ - "## Findings" grouped by bucket (highest first). Each finding: title,
+ file:line in code format, claim (3 sentences max), evidence quote in a
+ fenced code block, verifier consensus (2/3 or 3/3 + which lenses
+ confirmed), suggested direction (one sentence).
+ - For any dimension with zero surviving findings, an explicit
+ "no findings survived verification" line.
+5. Commit exactly one file:
+ git add ${AUDIT_FILE}
+ git commit -m "audit(delta): 0.9.0 multi-decoder delta audit findings"
+ Then run git status and confirm the tree is clean apart from untracked
+ files that existed before your run.
+
+Confirmed findings JSON:
+${JSON.stringify(confirmed, null, 2)}
+`
+
+// ───── Script body ──────────────────────────────────────────────────────────
+
+const SONNET = 'claude-sonnet-4-6'
+const OPUS = 'claude-opus-4-8'
+
+const FINDER_MODELS = {
+ decoder_routing: SONNET,
+ seam_parity: SONNET,
+ new_tests: OPUS, // Sonnet stalled twice on meta-review dimensions in the deep audit
+ docs_consistency: SONNET,
+}
+
+// args may arrive as a JSON string (depending on harness) — normalize.
+const cfg = typeof args === 'string' ? JSON.parse(args) : (args ?? {})
+const dims = cfg.dimensions ?? Object.keys(DIMENSION_PROMPTS)
+const unknownDims = dims.filter(d => !DIMENSION_PROMPTS[d])
+if (unknownDims.length) throw new Error(`Unknown dimensions: ${unknownDims.join(', ')}`)
+
+phase('Find')
+const findings = await parallel(
+ dims.map(dim => () =>
+ agent(`${DIMENSION_PROMPTS[dim]}\n\nReturn per schema.`, {
+ model: FINDER_MODELS[dim], schema: FINDING_SCHEMA,
+ label: `find:${dim}`, phase: 'Find',
+ })
+ ),
+)
+
+const FINDINGS_PER_DIM_CAP = 15
+const rawDimensionResults = findings.filter(Boolean)
+for (const r of rawDimensionResults) {
+ if (r.findings.length > FINDINGS_PER_DIM_CAP) {
+ const dimName = r.findings[0]?.dimension ?? ''
+ log(`WARNING: dimension ${dimName} returned ${r.findings.length} findings; capping at ${FINDINGS_PER_DIM_CAP}`)
+ }
+}
+const allFindings = rawDimensionResults.flatMap(r => r.findings.slice(0, FINDINGS_PER_DIM_CAP))
+log(`Found ${allFindings.length} candidate findings across ${dims.length} dimensions`)
+
+phase('Verify')
+const verified = await parallel(
+ allFindings.map(f => () =>
+ parallel(['code_reality', 'reproducer', 'spec_grounded'].map(lens => () =>
+ agent(VERIFIER_PROMPTS[lens](f), {
+ model: SONNET, schema: VERDICT_SCHEMA,
+ label: `verify:${f.dimension}:${lens}`, phase: 'Verify',
+ })
+ )).then(verdicts => {
+ const live = verdicts.filter(Boolean)
+ const confirms = live.filter(v => v.confirmed).length
+ const lensesConfirming = live.filter(v => v.confirmed).map(v => v.lens)
+ const adjustments = live.map(v => v.severity_adjustment).filter(Boolean)
+ let severity = f.suspected_severity
+ if (adjustments.filter(a => a === 'lower').length >= 1) severity = lowerOne(severity)
+ if (adjustments.filter(a => a === 'raise').length >= 2) severity = raiseOne(severity)
+ if (live.length === 0) {
+ log(`WARNING: all 3 verifiers failed for finding "${f.title}" (${f.file}:${f.line_hint}) — dropped`)
+ }
+ return confirms >= 2 ? { ...f, final_severity: severity, lensesConfirming } : null
+ })
+ ),
+)
+
+const confirmed = verified.filter(Boolean)
+log(`${confirmed.length}/${allFindings.length} findings confirmed by ≥2 verifiers`)
+
+phase('Synthesize')
+await agent(SYNTHESIS_PROMPT(dims, confirmed), { model: OPUS, label: 'synthesize' })
+
+return {
+ candidates: allFindings.length,
+ confirmed: confirmed.length,
+ by_severity: countBySeverity(confirmed),
+}
+
+// ───── Helpers ──────────────────────────────────────────────────────────────
+
+function lowerOne(s) {
+ const order = ['nit', 'low', 'medium', 'high', 'blocker']
+ const i = order.indexOf(s)
+ return i > 0 ? order[i - 1] : s
+}
+function raiseOne(s) {
+ const order = ['nit', 'low', 'medium', 'high', 'blocker']
+ const i = order.indexOf(s)
+ return i < order.length - 1 ? order[i + 1] : s
+}
+function countBySeverity(arr) {
+ const out = { blocker: 0, high: 0, medium: 0, low: 0, nit: 0 }
+ for (const f of arr) out[f.final_severity ?? f.suspected_severity]++
+ return out
+}
+```
+
+- [ ] **Step 2: Syntax-check the script**
+
+`node --check` cannot be used: the script has a top-level `return`, which the Workflow harness allows (it wraps the body in an async function) but which is a SyntaxError in real ESM module scope. Mirror the harness wrapping instead:
+
+```bash
+node -e "
+const s = require('fs').readFileSync('planning/audit/workflow-delta.mjs', 'utf8')
+ .replace('export const meta', 'const meta');
+new Function('args','agent','parallel','pipeline','phase','log','budget','workflow',
+ 'return (async () => {' + s + '})()');
+console.log('syntax OK');
+"
+```
+
+Expected output: `syntax OK` (exit 0). A SyntaxError here means the script is malformed — fix before committing.
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add planning/audit/workflow-delta.mjs
+git commit -m "audit(delta): add 0.9.0 delta audit workflow script"
+```
+
+---
+
+### Task 2: Run the delta audit workflow
+
+**Files:**
+- Created by the run: `planning/audit/2026-06-12-delta-audit.md` (written and committed by the synthesis agent, not by you)
+
+**This task runs inline in the main session — do not dispatch it to a subagent.**
+
+- [ ] **Step 1: Pre-flight checks**
+
+Run: `git status --porcelain` — expected: empty (clean tree).
+Run: `git tag --list 0.8.6` — expected: `0.8.6` (the baseline tag the finder prompts diff against exists).
+
+- [ ] **Step 2: Invoke the workflow**
+
+Call the Workflow tool with:
+
+```
+scriptPath: /Users/kevinsmith/src/pypi/httpware/planning/audit/workflow-delta.mjs
+```
+
+No `args` (defaults to all four dimensions). The tool returns immediately with a task ID; the run completes in the background and sends a task notification.
+
+- [ ] **Step 3: Await completion and read the result**
+
+Expected return shape: `{ candidates: N, confirmed: M, by_severity: { blocker, high, medium, low, nit } }`.
+
+Failure handling:
+- A finder returning `null` (skipped/terminal error) is logged by the script and the run continues with the remaining dimensions — note the gap for the final report.
+- If the run dies mid-flight, resume with `Workflow({scriptPath, resumeFromRunId})` — completed finder/verifier calls return cached.
+- Zero confirmed findings is a valid outcome; synthesis still writes the doc with per-dimension "no findings survived" notes.
+
+---
+
+### Task 3: Verify acceptance criteria and report
+
+- [ ] **Step 1: Verify the synthesis commit touched only the audit doc**
+
+Run: `git show --stat HEAD --format='%s'`
+Expected: subject `audit(delta): 0.9.0 multi-decoder delta audit findings`, exactly one file changed: `planning/audit/2026-06-12-delta-audit.md`.
+
+Run: `git status --porcelain`
+Expected: empty. If the synthesis agent modified or created anything else, revert those changes (`git checkout -- ` / delete strays) and note the constraint violation in the report.
+
+- [ ] **Step 2: Spot-check the audit doc format**
+
+Read `planning/audit/2026-06-12-delta-audit.md` and confirm against the spec's acceptance criteria:
+- Summary header with bucket counts and headline.
+- Every finding has: title, `file:line`, claim ≤3 sentences, fenced evidence quote, verifier consensus with lenses, suggested direction.
+- Each of the 4 dimensions either has findings or an explicit "no findings survived verification" line.
+
+If format gaps exist, fix the doc directly (formatting only — never alter finding substance) and amend the synthesis commit.
+
+- [ ] **Step 3: Report to the user**
+
+Post a summary: bucket counts, the headline finding, any dimension gaps or constraint violations, and a pointer to the audit doc. Findings are the deliverable — do not start fixing them.
diff --git a/planning/plans/2026-06-13-msgspec-nested-customtype-fix-plan.md b/planning/plans/2026-06-13-msgspec-nested-customtype-fix-plan.md
new file mode 100644
index 0000000..ee049c4
--- /dev/null
+++ b/planning/plans/2026-06-13-msgspec-nested-customtype-fix-plan.md
@@ -0,0 +1,315 @@
+# MsgspecDecoder Nested-CustomType Fix Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Make `MsgspecDecoder.can_decode` return `False` for parameterized containers whose element type msgspec cannot natively decode (`list[PUser]`, `dict[str, PUser]`, etc.), so the client's `MissingDecoderError` pre-flight fires before a request is sent.
+
+**Architecture:** Replace the top-level-only `isinstance(info, CustomType)` check in `can_decode` with a module-level recursive walker `_contains_custom_type` that rejects when any node anywhere in the `msgspec.inspect.type_info` tree is a `CustomType`. The walk is generic (visits any attribute that is a `Type` or tuple-of-`Type`) so it covers all container kinds and arbitrary nesting, and it stops at `Struct`/dataclass field boundaries automatically (those expose `fields` as `Field`, not `Type`), avoiding over-rejection and recursive-type loops.
+
+**Tech Stack:** Python 3.11+, msgspec (`msgspec.inspect`), pydantic (test fixtures), pytest (`pytest-asyncio` auto mode), httpx2 `MockTransport`.
+
+**Spec:** [planning/specs/2026-06-13-msgspec-nested-customtype-fix-design.md](../specs/2026-06-13-msgspec-nested-customtype-fix-design.md)
+
+---
+
+## File Structure
+
+| File | Responsibility | Change |
+|---|---|---|
+| `src/httpware/decoders/msgspec.py` | `MsgspecDecoder` + new `_contains_custom_type` helper | Modify |
+| `tests/test_decoders_msgspec.py` | Unit tests for `can_decode` rejection/acceptance | Modify (append tests) |
+| `tests/test_client_dispatch.py` | Integration regression: msgspec-only client raises `MissingDecoderError` without a request | Modify (append tests) |
+| `planning/releases/0.9.1.md` | Patch release notes | Create |
+
+No new source files. The walker lives beside `MsgspecDecoder` because it is private to that module and changes with it.
+
+---
+
+### Task 1: Recursive CustomType rejection in `can_decode`
+
+**Files:**
+- Modify: `src/httpware/decoders/msgspec.py:40-58` (the `can_decode` method) and add a module-level helper
+- Test: `tests/test_decoders_msgspec.py` (append after the existing `test_msgspec_rejects_pydantic_basemodel`, ~line 110)
+
+- [ ] **Step 1: Write the failing rejection + acceptance tests**
+
+Append to `tests/test_decoders_msgspec.py`. The file already imports `msgspec`, `pydantic`, `pytest` and defines `_Item` (Struct), `_PydanticUser` (BaseModel), `_DC` (dataclass). Use PEP 604 `| None` for the optional case — the project forbids `from __future__ import annotations` and uses native union syntax, so no `typing` import is needed.
+
+```python
+@pytest.mark.parametrize(
+ "model",
+ [
+ list[_PydanticUser],
+ dict[str, _PydanticUser],
+ _PydanticUser | None,
+ tuple[_PydanticUser, int],
+ list[list[_PydanticUser]],
+ set[_PydanticUser],
+ ],
+)
+def test_msgspec_rejects_containers_of_pydantic_models(model: type) -> None:
+ """Nested CustomType (a pydantic model inside a container) must be rejected.
+
+ Before the fix, can_decode inspected only the top-level type_info node, so a
+ container parameterized by a BaseModel slipped past and built a decoder via
+ the CustomType fallback — bypassing the MissingDecoderError pre-flight.
+ """
+ assert MsgspecDecoder().can_decode(model) is False
+
+
+@pytest.mark.parametrize(
+ "model",
+ [
+ _Item,
+ list[_Item],
+ dict[str, _Item],
+ list[list[_Item]],
+ dict[str, int],
+ list[int],
+ int,
+ ],
+)
+def test_msgspec_still_accepts_native_containers(model: type) -> None:
+ """Containers parameterized only by msgspec-native types stay accepted.
+
+ Guards against the recursive walker over-rejecting: the walk must stop at
+ Struct boundaries (StructType.fields are Field, not Type) and must not flag
+ plain builtin element types.
+ """
+ assert MsgspecDecoder().can_decode(model) is True
+```
+
+- [ ] **Step 2: Run the new tests and confirm the rejection set FAILS**
+
+Run: `just test tests/test_decoders_msgspec.py -k "rejects_containers or still_accepts_native"`
+
+Expected: `test_msgspec_rejects_containers_of_pydantic_models` FAILS for every param (each currently returns `True` because the top-level node is a container, not a `CustomType`). `test_msgspec_still_accepts_native_containers` PASSES (those are genuinely accepted today). This proves the rejection tests target the real bug.
+
+- [ ] **Step 3: Add the recursive walker**
+
+In `src/httpware/decoders/msgspec.py`, after the `import msgspec` guard block and the `MISSING_DEPENDENCY_MESSAGE` / `T = TypeVar("T")` lines (before `class MsgspecDecoder`), add:
+
+```python
+def _contains_custom_type(info: "msgspec.inspect.Type") -> bool:
+ """Return True if `info` is a CustomType or nests one in its parameters.
+
+ Walks generic-container parameterization (list/dict/set/tuple/union element
+ types) by visiting any attribute that is itself a `msgspec.inspect.Type` or a
+ tuple of them. It deliberately does NOT descend into `StructType`/dataclass
+ fields: those expose `fields` as `Field` objects (not `Type`), so the walk
+ stops at the boundary of a type msgspec natively owns. That boundary is what
+ makes the walk both correct (a Struct is a valid target) and safe against
+ infinite recursion on self-referential struct definitions.
+ """
+ if isinstance(info, msgspec.inspect.CustomType):
+ return True
+ for name in dir(info):
+ if name.startswith("_"):
+ continue
+ value = getattr(info, name, None)
+ if isinstance(value, msgspec.inspect.Type):
+ if _contains_custom_type(value):
+ return True
+ elif (
+ isinstance(value, tuple)
+ and value
+ and all(isinstance(item, msgspec.inspect.Type) for item in value)
+ ):
+ if any(_contains_custom_type(item) for item in value):
+ return True
+ return False
+```
+
+- [ ] **Step 4: Update `can_decode` to use the walker**
+
+Replace the existing `can_decode` method (`src/httpware/decoders/msgspec.py:40-58`) with:
+
+```python
+ def can_decode(self, model: type) -> bool:
+ """Return True iff msgspec natively understands `model` end-to-end.
+
+ msgspec builds a Decoder for almost any class via a generic CustomType
+ fallback; the Decoder constructor does NOT raise on unsupported types
+ (e.g. pydantic.BaseModel, or a container parameterized by one). We walk
+ msgspec.inspect.type_info and reject if a CustomType appears anywhere in
+ the type tree, so MissingDecoderError fires before a request is sent.
+ """
+ try:
+ info = msgspec.inspect.type_info(model)
+ except Exception: # noqa: BLE001 — can_decode is a probe; any failure means no
+ return False
+ if _contains_custom_type(info):
+ return False
+ try:
+ self._get_msgspec_decoder(model)
+ except Exception: # noqa: BLE001 — can_decode is a probe; any failure means no
+ return False
+ return True
+```
+
+The second `try/except` (decoder-build probe) is kept as defense for any non-`CustomType` shape msgspec still refuses to build.
+
+- [ ] **Step 5: Run the unit tests and confirm all PASS**
+
+Run: `just test tests/test_decoders_msgspec.py`
+
+Expected: PASS — both new parametrized tests green, and every pre-existing `can_decode` test (`test_msgspec_can_decode_struct`, `_dataclass`, `_dict`, `_list_of_structs`, `_primitive_int`, `_rejects_pydantic_basemodel`, `_uses_cache`, the two `patch`-based soft-no tests, `_unhashable_model_falls_back...`) still green — confirming no regression.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add src/httpware/decoders/msgspec.py tests/test_decoders_msgspec.py
+git commit -m "fix(decoders): reject nested CustomType in MsgspecDecoder.can_decode
+
+list[PUser], dict[str, PUser], and other containers parameterized by a
+pydantic BaseModel (any CustomType-falling element) were wrongly accepted
+because can_decode inspected only the top-level type_info node. They now
+route to MissingDecoderError before a request is sent.
+
+Closes the High finding in planning/audit/2026-06-12-delta-audit.md.
+
+Co-Authored-By: Claude Fable 5 "
+```
+
+---
+
+### Task 2: Dispatch-level regression (msgspec-only client, no request sent)
+
+**Files:**
+- Test: `tests/test_client_dispatch.py` (append at end of file)
+
+This task encodes the user-facing symptom from the spec (acceptance criterion 3): a msgspec-only client asked for `list[_PydanticUser]` must raise `MissingDecoderError` **without** sending a request. The file already imports `AsyncClient, Client, MissingDecoderError`, `MsgspecDecoder`, `httpx2`, `pytest`, `HTTPStatus`, and defines `_PydanticUser`.
+
+- [ ] **Step 1: Write the sync + async regression tests**
+
+Append to `tests/test_client_dispatch.py`. The "transport must not be invoked" pattern (`pytest.fail` inside the handler, `# pragma: no cover`) mirrors the existing `test_async_missing_decoder_when_none_claim`.
+
+```python
+async def test_async_msgspec_only_list_of_basemodel_preflight_raises() -> None:
+ """msgspec-only client + response_model=list[BaseModel] must raise
+ MissingDecoderError before any request is sent (the 0.9.0-delta bug)."""
+
+ def handler(_: httpx2.Request) -> httpx2.Response: # pragma: no cover
+ pytest.fail("transport should not be invoked: pre-flight must reject first")
+
+ transport = httpx2.MockTransport(handler)
+ client = AsyncClient(
+ httpx2_client=httpx2.AsyncClient(transport=transport),
+ decoders=[MsgspecDecoder()],
+ )
+ with pytest.raises(MissingDecoderError):
+ await client.get("https://example.test/x", response_model=list[_PydanticUser])
+
+
+def test_sync_msgspec_only_list_of_basemodel_preflight_raises() -> None:
+ """Sync twin of the msgspec-only pre-flight regression."""
+
+ def handler(_: httpx2.Request) -> httpx2.Response: # pragma: no cover
+ pytest.fail("transport should not be invoked: pre-flight must reject first")
+
+ transport = httpx2.MockTransport(handler)
+ client = Client(
+ httpx2_client=httpx2.Client(transport=transport),
+ decoders=[MsgspecDecoder()],
+ )
+ with pytest.raises(MissingDecoderError):
+ client.get("https://example.test/x", response_model=list[_PydanticUser])
+```
+
+- [ ] **Step 2: Run the regression tests and confirm PASS**
+
+Run: `just test tests/test_client_dispatch.py -k "msgspec_only_list_of_basemodel"`
+
+Expected: PASS (both). These are green on arrival because Task 1 fixed the root cause — their role is to lock the user-facing contract at the client seam.
+
+- [ ] **Step 3: Confirm the regression genuinely guards the behavior**
+
+Prove the tests would have caught the pre-fix bug by temporarily reverting only the source change:
+
+```bash
+git stash push -- src/httpware/decoders/msgspec.py
+just test tests/test_client_dispatch.py -k "msgspec_only_list_of_basemodel"
+git stash pop
+```
+
+Expected: with the fix stashed, both tests FAIL — the handler's `pytest.fail` fires (a request is sent) instead of `MissingDecoderError`. After `git stash pop`, re-running shows PASS again. (If `git stash pop` reports a conflict, resolve by keeping the popped version — it is the fixed `msgspec.py`.)
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add tests/test_client_dispatch.py
+git commit -m "test(client): regression for msgspec-only nested-CustomType pre-flight
+
+A msgspec-only client asked for list[_PydanticUser] now raises
+MissingDecoderError without sending a request. Sync + async twins.
+
+Co-Authored-By: Claude Fable 5 "
+```
+
+---
+
+### Task 3: Full verification and 0.9.1 release notes
+
+**Files:**
+- Create: `planning/releases/0.9.1.md`
+
+The project uses bare-semver git tags with hand-written release notes under `planning/releases/`; `pyproject.toml`'s `version` field is not the release source of truth and is **not** bumped here. Tagging/publishing is a manual finishing step, out of scope for this plan.
+
+- [ ] **Step 1: Run the full lint + test suite**
+
+Run: `just lint && just test`
+
+Expected: lint clean (eof-fixer + ruff format + ruff check + ty check all pass — in particular no `BLE001`/`SLF001` surprises from the new helper, which carries its own `# noqa` only where the existing code does), and the full pytest suite green with no regressions.
+
+- [ ] **Step 2: Write the release notes**
+
+Create `planning/releases/0.9.1.md` (match the prose style of `planning/releases/0.8.1.md` — a one-line headline, "The gap", "The fix"):
+
+```markdown
+# httpware 0.9.1 — `MsgspecDecoder` stops claiming containers it can't decode
+
+**Patch release with one behavior change.** When `MsgspecDecoder` is the only decoder registered (an msgspec-only install, or an explicit `decoders=[MsgspecDecoder()]`), a `response_model=` of `list[SomePydanticModel]`, `dict[str, SomePydanticModel]`, `SomePydanticModel | None`, or any container parameterized by a type msgspec can't natively decode now raises `MissingDecoderError` *before* a request is sent — instead of sending the request and failing at decode with `DecodeError`.
+
+## The gap
+
+`MsgspecDecoder.can_decode` answers the client's pre-flight question "can you decode this type?" — and on a `False` from every registered decoder, the client raises `MissingDecoderError` without touching the network. msgspec builds a `json.Decoder` for almost any type via a generic `CustomType` fallback, so `can_decode` used `msgspec.inspect.type_info` to detect and reject that fallback. But it inspected only the **top-level** node: `type_info(list[PUser])` is a `ListType` whose `item_type` is the `CustomType`, so the top-level check passed, the decoder built, and `can_decode` returned `True`. The pre-flight was bypassed, a real HTTP request went out, and `decode` then raised a validation error (surfaced as `DecodeError`). The false-positive was cached per instance, so every later request of that shape repeated the wasted round-trip.
+
+Under the default pydantic-first `decoders=[PydanticDecoder(), MsgspecDecoder()]`, this was masked — pydantic claims `list[PUser]` first. The bug only bit msgspec-only configurations.
+
+## The fix
+
+`can_decode` now walks the full `type_info` tree and rejects if a `CustomType` appears **anywhere** in it, via a recursive helper that visits every nested element type (`list`/`dict`/`set`/`tuple`/`Optional`/`Union`, arbitrarily nested). The walk stops at `Struct`/dataclass field boundaries automatically, so genuine msgspec targets like `list[SomeStruct]` stay accepted and self-referential structs can't loop.
+
+No public-API change: the `ResponseDecoder` protocol and `can_decode`'s signature are unchanged. Only the set of types `MsgspecDecoder` claims is corrected.
+```
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add planning/releases/0.9.1.md
+git commit -m "docs(release): draft 0.9.1 notes — msgspec nested-CustomType fix
+
+Co-Authored-By: Claude Fable 5 "
+```
+
+- [ ] **Step 4: Report completion**
+
+Summarize: the fix, the two test layers (unit rejection/acceptance + sync/async dispatch regression), full-suite green, and the drafted 0.9.1 notes. Note that tagging `0.9.1` is the remaining manual step. Then follow `superpowers:finishing-a-development-branch` to decide integration (this work is on `main` consistent with prior patch releases).
+
+---
+
+## Self-Review
+
+**Spec coverage:**
+- Recursive `CustomType` rejection + walker design → Task 1, Steps 3–4 (helper + `can_decode`).
+- Walker stops at Struct boundary / no over-rejection → Task 1 acceptance test (`test_msgspec_still_accepts_native_containers`).
+- Acceptance criterion 1 (reject `list[PUser]` & variants) → Task 1 rejection test.
+- Acceptance criterion 2 (still accept `list[Struct]`, `Struct`, `dict[str,int]`) → Task 1 acceptance test.
+- Acceptance criterion 3 (msgspec-only client raises `MissingDecoderError`, no request) → Task 2 sync+async tests.
+- Acceptance criterion 4 (`just lint` + `just test` pass; no `pydantic.py` change; no API change) → Task 3 Step 1; no task touches `pydantic.py`.
+- Acceptance criterion 5 (ships as 0.9.1 patch) → Task 3 release notes (tagging noted as manual).
+- "Out of scope: perf nits, no `pydantic.py`" → honored; no caching changes in any task.
+
+**Placeholder scan:** none — every code/command step shows literal content.
+
+**Type/name consistency:** `_contains_custom_type` referenced identically in Steps 3 and 4; test fixtures (`_Item`, `_PydanticUser`, `_DC`) and helpers (`_async_client_with_body`/`_sync_client_with_body` not needed — Task 2 builds clients inline to attach a `pytest.fail` handler, matching the existing `test_async_missing_decoder_when_none_claim` pattern) all match the real files read during planning.
diff --git a/planning/releases/0.9.1.md b/planning/releases/0.9.1.md
new file mode 100644
index 0000000..04c97f6
--- /dev/null
+++ b/planning/releases/0.9.1.md
@@ -0,0 +1,15 @@
+# httpware 0.9.1 — `MsgspecDecoder` stops claiming containers it can't decode
+
+**Patch release with one behavior change.** When `MsgspecDecoder` is the only decoder registered (an msgspec-only install, or an explicit `decoders=[MsgspecDecoder()]`), a `response_model=` of `list[SomePydanticModel]`, `dict[str, SomePydanticModel]`, `SomePydanticModel | None`, or any container parameterized by a type msgspec can't natively decode now raises `MissingDecoderError` *before* a request is sent — instead of sending the request and failing at decode with `DecodeError`.
+
+## The gap
+
+`MsgspecDecoder.can_decode` answers the client's pre-flight question "can you decode this type?" — and on a `False` from every registered decoder, the client raises `MissingDecoderError` without touching the network. msgspec builds a `json.Decoder` for almost any type via a generic `CustomType` fallback, so `can_decode` used `msgspec.inspect.type_info` to detect and reject that fallback. But it inspected only the **top-level** node: `type_info(list[PUser])` is a `ListType` whose `item_type` is the `CustomType`, so the top-level check passed, the decoder built, and `can_decode` returned `True`. The pre-flight was bypassed, a real HTTP request went out, and `decode` then raised a validation error (surfaced as `DecodeError`). The false-positive was cached per instance, so every later request of that shape repeated the wasted round-trip.
+
+Under the default pydantic-first `decoders=[PydanticDecoder(), MsgspecDecoder()]`, this was masked — pydantic claims `list[PUser]` first. The bug only bit msgspec-only configurations.
+
+## The fix
+
+`can_decode` now walks the full `type_info` tree and rejects if a `CustomType` appears **anywhere** in it, via a recursive helper that visits every nested element type (`list`/`dict`/`set`/`tuple`/`Optional`/`Union`, arbitrarily nested). The walk stops at `Struct`/dataclass field boundaries automatically, so genuine msgspec targets like `list[SomeStruct]` stay accepted and self-referential structs can't loop.
+
+No public-API change: the `ResponseDecoder` protocol and `can_decode`'s signature are unchanged. Only the set of types `MsgspecDecoder` claims is corrected.
diff --git a/planning/specs/2026-06-12-delta-audit-design.md b/planning/specs/2026-06-12-delta-audit-design.md
new file mode 100644
index 0000000..d0a85c3
--- /dev/null
+++ b/planning/specs/2026-06-12-delta-audit-design.md
@@ -0,0 +1,135 @@
+# Spec: Delta audit of the 0.9.0 multi-decoder epic — code + docs
+
+**Date:** 2026-06-12
+**Baseline:** tag `0.8.6` (last commit covered by the 2026-06-07 deep audit, all findings closed by 0.8.6)
+**Head:** current `main` (`ab25469`, post-0.9.0)
+**Prior art:** [2026-06-07-deep-audit-design.md](2026-06-07-deep-audit-design.md), [planning/audit/workflow.mjs](../audit/workflow.mjs), [planning/audit/2026-06-07-deep-audit.md](../audit/2026-06-07-deep-audit.md)
+
+## Purpose
+
+Audit everything that changed since the deep audit closed — the 0.9.0 multi-decoder routing epic (PRs #41, #42) plus the docs rewrite and GH Pages migration — and sweep the *entire* docs surface for consistency with the new decoder story. The deep audit's machinery is proven; this run adapts it to a ~1k-line delta instead of the full repo.
+
+The deliverable is a verified, severity-bucketed findings document. No fixes in this effort; fixes become follow-up specs, as with the prior audit.
+
+## Non-goals
+
+- Re-auditing unchanged code (`middleware/`, resilience internals, `_internal/`) — swept four days ago, untouched since.
+- Performance, security, supply-chain dimensions (same exclusion as the deep audit).
+- Fixing anything, including trivial typos.
+- Re-running the stalled full-suite `tests` dimension from the deep audit; this run covers only the ~700 new test lines.
+
+## Audit scope (what the finders read)
+
+Code + tests, from `git diff 0.8.6..HEAD`:
+
+- `src/httpware/client.py` — `decoders=[...]` parameter, type-dispatched routing, `_build_default_decoders`, `MissingDecoderError` pre-flight
+- `src/httpware/decoders/__init__.py`, `decoders/pydantic.py`, `decoders/msgspec.py` — `can_decode` predicate, per-instance caches
+- `src/httpware/errors.py` — `MissingDecoderError`
+- `src/httpware/__init__.py` — new exports
+- Changed tests: `test_client_construction.py`, `test_client_decoders_default.py`, `test_client_dispatch.py`, `test_client_send_with_response{,_sync}.py`, `test_client_sync.py`, `test_decoders_msgspec.py`, `test_decoders_pydantic.py`, `test_errors.py`, `test_optional_extras_pydantic_missing.py`, `test_public_api.py`
+
+Docs surface (full sweep, not delta — 0.9.0 changed the decoder narrative and reversed the 0.3.0 fail-fast behavior, so unchanged pages may now be stale):
+
+- `docs/index.md`, `docs/errors.md`, `docs/middleware.md`, `docs/resilience.md`, `docs/testing.md`, `docs/recipes/*.md`, `docs/dev/*.md`
+- `README.md`, `CLAUDE.md`
+- `planning/engineering.md`, `planning/deferred-work.md`, `planning/releases/0.9.0.md`
+- `mkdocs.yml` nav vs. files on disk
+
+## Architecture
+
+Single Workflow run (one chunk — the delta is too small to justify the deep audit's 4-chunk gating). Pipeline reuses the deep-audit skeleton:
+
+1. **Find** — 4 dimension finders in parallel (below). No discover phase: the file lists above are embedded verbatim in each finder prompt, replacing `_discover.json`.
+2. **Verify** — every candidate finding judged by the existing 3-voter panel (`code_reality` / `reproducer` / `spec_grounded` lenses); survives at ≥2 confirms; severity raised on ≥2 `raise` votes, lowered on ≥1 `lower` vote. Per-dimension candidate cap: 15.
+3. **Synthesize** — one agent writes the audit doc and commits it.
+
+### Lessons from the deep audit baked in
+
+- **Test-quality finder gets Opus.** Sonnet stalled twice (~1.5M tokens, zero findings) on meta-review dimensions; the `new_tests` finder runs on Opus with a narrower target (~700 lines, named files).
+- **Synthesis must not create other files.** The synthesis prompt explicitly forbids writing or editing anything except the audit doc.
+- **args JSON.parse shim retained.** `args` may arrive as a JSON string; normalize before use.
+
+### Model assignment
+
+- Finders 1, 2, 4: Sonnet (`claude-sonnet-4-6`)
+- Finder 3 (`new_tests`): Opus (`claude-opus-4-8`)
+- Verifiers: Sonnet
+- Synthesis: Opus
+
+## The 4 dimensions
+
+### 1. `decoder_routing` (Sonnet)
+
+Correctness of the new dispatch machinery: `can_decode` first-match ordering semantics; `MissingDecoderError` pre-flight timing (must raise before the request is sent, per the 0.9.0 spec); the default-decoder resolution matrix in `_build_default_decoders` (installed-extras detection); per-instance adapter/decoder cache behavior (PR #42 — no shared module state, no cross-instance leakage); the msgspec `CustomType` quirk — `msgspec.json.Decoder(BaseModel)` *succeeds* via CustomType fallback rather than raising, so `can_decode` must use `msgspec.inspect.type_info` filtering, not try/except.
+
+Out of scope: sync/async divergence (dimension 2), test quality (3), docs (4).
+
+### 2. `seam_parity` (Sonnet)
+
+Seam B contract and sync/async parity of the changed code: both `Client.send` and `AsyncClient.send` must invoke decoder routing identically; `send_with_response` (both sides) must route through the same dispatch as `send`; `DecodeError` must still wrap decoder failures at Seam B (0.8.1 contract); `MissingDecoderError` must conform to the exception-construction conventions in CLAUDE.md (or be a documented deliberate deviation — it is not status-keyed, so the single-positional-`response` rule may legitimately not apply; the finder should check what the code does against what `errors.py` docstrings and `engineering.md` claim).
+
+Out of scope: routing logic bugs (dimension 1), docs accuracy (4).
+
+### 3. `new_tests` (Opus)
+
+Quality of the changed/new test files only (named list above): tautological asserts (a known reviewer blind spot from the audit-closure retro); dispatch-matrix coverage gaps (decoder order, overlapping `can_decode`, zero-decoder client, model type matching no decoder); sync/async test parity for every new behavior; mocks/transports that hide real httpx2 behavior; tests passing for the wrong reason.
+
+Out of scope: production-code bugs (1, 2), pre-existing test files untouched by the delta.
+
+### 4. `docs_consistency` (Sonnet)
+
+Every code block and load-bearing claim in the full docs surface, verified against 0.9.0 reality: residual `decoder=` (singular) usage anywhere; the fail-fast story — 0.3.0's "raise at `__init__` when extra missing" was *reversed* by 0.9.0's `MissingDecoderError` pre-flight, so any page still describing init-time failure is wrong; Seam B descriptions in `engineering.md` and CLAUDE.md vs. the actual `decode(content, model)` + `can_decode` surface; `planning/deferred-work.md` items quietly resolved (the module-global `lru_cache` item appears closed by PR #42); broken internal links; mkdocs nav vs. files on disk; release-notes claims in `planning/releases/0.9.0.md`.
+
+When a docs finding is verified, the verifier must state whether the fix belongs in the doc or in the code; if code, synthesis recategorizes it to dimension 1 or 2.
+
+Out of scope: prose style, doc structure opinions (the docs-philosophy memory: no autodoc, no migration guides — absence of a migration guide is not a finding).
+
+## Schemas, verification, severity
+
+Reuse the deep-audit schemas unchanged: `FINDING_SCHEMA` (dimension, title, file, line_hint, claim, evidence_quote, suspected_severity, reproducer_hint), `VERDICT_SCHEMA` (lens, confirmed, reason, quoted_evidence, severity_adjustment), and the severity bucket definitions:
+
+- **Blocker** — wrong-correctness bug affecting users in normal usage; documented invariant violated; doc example that does not run.
+- **High** — bug behind a non-default path; missing safety check at a documented boundary; docs that mislead a reasonable reader.
+- **Medium** — works today but relies on undocumented invariants; accurate-but-ambiguous docs; test gaps in load-bearing primitives.
+- **Low** — minor inaccuracies, weak idioms, hardening suggestions.
+- **Nit** — style, naming, punctuation; collapsed into one rolled-up entry per dimension if more than 4 surface.
+
+## Output
+
+`planning/audit/2026-06-12-delta-audit.md`, same per-finding format as the deep audit (title, `file:line`, claim ≤3 sentences, fenced evidence quote, verifier consensus with lenses, suggested direction), but single-section — no chunk headers. Top-of-file summary with bucket counts and the headline finding. Synthesis commits the file as `audit(delta): 0.9.0 multi-decoder delta audit findings`.
+
+The adapted script is saved as `planning/audit/workflow-delta.mjs` and committed alongside, mirroring how `workflow.mjs` was kept.
+
+## File layout
+
+```
+planning/audit/
+├── workflow.mjs # deep-audit script (unchanged, kept)
+├── workflow-delta.mjs # this run's adapted script (new)
+└── 2026-06-12-delta-audit.md # the report (new)
+```
+
+No `_discover.json` equivalent — file lists are inlined in prompts.
+
+## Token budget (estimate)
+
+One chunk: ~600k–900k Sonnet (3 finders + ~3 verifiers × ~20–35 surviving candidates) + ~150k Opus (`new_tests` finder + synthesis). Roughly a quarter of the deep audit's spend.
+
+## Risks & mitigations
+
+- **Finders re-surface closed deep-audit findings.** Mitigation: each finder prompt names the baseline (`0.8.6`) and links the prior audit doc; the `spec_grounded` verifier checks candidates against the prior audit's closed list; synthesis dedupes against it.
+- **`docs_consistency` drowns in nits after a full-site sweep.** Mitigation: 15-candidate cap, nit roll-up rule, and the docs-philosophy exclusions stated in the prompt.
+- **Opus `new_tests` finder over-reports style opinions.** Mitigation: prompt anchors on "passes for the wrong reason / gap in the dispatch matrix" framing with the same default-to-silence instruction as the other finders.
+- **False positives.** Mitigation: unchanged 2-of-3 consensus with verifiers defaulting to `confirmed: false`.
+
+## Open questions for writing-plans
+
+- Exact finder prompt text (the plan drafts them; this spec fixes their scope and exclusions).
+- Whether the `0.8.6..HEAD` diff itself (or just file lists) is embedded in finder prompts. Default: file lists + instruction to run `git diff 0.8.6..HEAD -- ` themselves, so finders see change context without bloating prompts.
+
+## Acceptance criteria
+
+1. `planning/audit/2026-06-12-delta-audit.md` exists, committed, with findings from all four dimensions (or an explicit "no findings survived" note per dimension).
+2. Each finding carries title, `file:line`, claim, evidence quote, verifier consensus, suggested direction.
+3. No file other than the audit doc and `workflow-delta.mjs` is created or modified by the run.
+4. The user has reviewed the report as the audit deliverable.
diff --git a/planning/specs/2026-06-13-msgspec-nested-customtype-fix-design.md b/planning/specs/2026-06-13-msgspec-nested-customtype-fix-design.md
new file mode 100644
index 0000000..a570a3e
--- /dev/null
+++ b/planning/specs/2026-06-13-msgspec-nested-customtype-fix-design.md
@@ -0,0 +1,129 @@
+# Spec: Fix `MsgspecDecoder.can_decode` false-positive on nested CustomType
+
+**Date:** 2026-06-13
+**Source:** [2026-06-12-delta-audit.md](../audit/2026-06-12-delta-audit.md) — High finding #1 (`src/httpware/decoders/msgspec.py:48`)
+**Related:** the top-level `CustomType` rejection landed with multi-decoder routing in 0.9.0; this fixes the nested-container extension of the same quirk.
+
+## Purpose
+
+`MsgspecDecoder.can_decode(model)` must return `True` only for model types msgspec can natively decode end-to-end. Today it returns `True` for parameterized containers whose element type msgspec cannot decode — `list[PUser]`, `dict[str, PUser]`, `Optional[PUser]`, `tuple[PUser, int]`, and any nesting thereof, where `PUser` is a `pydantic.BaseModel` (or any other type that falls back to msgspec's `CustomType`). This bypasses the `MissingDecoderError` pre-flight at the client dispatch seam, so a real HTTP request is sent and then `decode` raises `msgspec.ValidationError` (surfaced as `DecodeError`). The false-positive is cached in the per-instance `_msgspec_decoders` dict, so every subsequent request with that model type repeats the wasted round-trip.
+
+## Root cause
+
+`can_decode` rejects only when the **top-level** `type_info` result is a `CustomType`:
+
+```python
+info = msgspec.inspect.type_info(model)
+if isinstance(info, msgspec.inspect.CustomType):
+ return False
+```
+
+For `list[PUser]`, `type_info` returns `ListType(item_type=CustomType(...))` — the top-level node is `ListType`, so the guard passes. `msgspec.json.Decoder(list[PUser])` then **builds successfully** (msgspec defers the `CustomType` to a `dec_hook` that httpware never configures), so the second `try/except` probe also passes and `can_decode` returns `True`.
+
+Empirically confirmed (msgspec 0.21.1):
+
+| model | `type_info` top node | nested CustomType? | `Decoder` builds? | `can_decode` today |
+|---|---|---|---|---|
+| `PUser` | `CustomType` | — | yes | `False` ✅ |
+| `list[PUser]` | `ListType` | `item_type` | yes | `True` ❌ |
+| `dict[str, PUser]` | `DictType` | `value_type` | yes | `True` ❌ |
+| `Optional[PUser]` | `UnionType` | `types[0]` | yes | `True` ❌ |
+| `tuple[PUser, int]` | `TupleType` | `item_types[0]` | yes | `True` ❌ |
+| `list[list[PUser]]` | `ListType` | `item_type.item_type` | yes | `True` ❌ |
+| `list[Struct]`, `Struct`, `dict[str,int]` | container / `StructType` | none | yes | `True` ✅ |
+
+## Design
+
+Replace the single top-level `isinstance` check with a recursive walk of the `type_info` tree. Reject when **any** node anywhere in the tree is a `CustomType`.
+
+### The walker
+
+A module-level private helper in `src/httpware/decoders/msgspec.py`:
+
+```python
+def _contains_custom_type(info: "msgspec.inspect.Type") -> bool:
+ """True if `info` is a CustomType or has any nested Type that is.
+
+ Walks generic-container parameterization (list/dict/set/tuple/union element
+ types) by visiting any attribute that is itself a `msgspec.inspect.Type` or a
+ tuple of them. It deliberately does NOT descend into `StructType`/dataclass
+ fields: those expose `fields` as `Field` objects (not `Type`), so the walk
+ stops at the boundary of a type msgspec natively owns — which also makes
+ recursive struct definitions safe from infinite recursion.
+ """
+ if isinstance(info, msgspec.inspect.CustomType):
+ return True
+ for name in dir(info):
+ if name.startswith("_"):
+ continue
+ value = getattr(info, name, None)
+ if isinstance(value, msgspec.inspect.Type):
+ if _contains_custom_type(value):
+ return True
+ elif isinstance(value, tuple) and value and all(
+ isinstance(item, msgspec.inspect.Type) for item in value
+ ):
+ if any(_contains_custom_type(item) for item in value):
+ return True
+ return False
+```
+
+**Why generic introspection over per-container-kind code:** enumerating `ListType.item_type`, `DictType.value_type`, `TupleType.item_types`, `UnionType.types`, `SetType.item_type`, `FrozenSetType.item_type` by hand is brittle — a new msgspec container kind would silently re-open the bug. Walking any `Type`-typed attribute covers all current containers, arbitrary nesting, and future kinds with no maintenance.
+
+**Why the Struct boundary is correct and safe:** `StructType.fields` is a tuple of `msgspec.inspect.Field` (not `Type`), so the `all(isinstance(item, Type) ...)` guard skips it. The walker never enters struct/dataclass/TypedDict field types. This is the intended scope — a `Struct` is a type msgspec owns natively; `can_decode` answers "does msgspec own the *container shape*," and per-field pathologies (a Struct with a pydantic-model field) are out of scope for this fix. It also means a self-referential `Struct` (`next: Optional[Node]`) cannot drive infinite recursion through this helper.
+
+### Updated `can_decode`
+
+```python
+def can_decode(self, model: type) -> bool:
+ """Return True iff msgspec natively understands `model` end-to-end.
+
+ msgspec builds a Decoder for almost any class via a generic CustomType
+ fallback; the Decoder constructor does NOT raise on unsupported types
+ (e.g. pydantic.BaseModel, or a container parameterized by one). We use
+ msgspec.inspect.type_info and reject if a CustomType appears anywhere in
+ the type tree, so MissingDecoderError fires before a request is sent.
+ """
+ try:
+ info = msgspec.inspect.type_info(model)
+ except Exception: # noqa: BLE001 — can_decode is a probe; any failure means no
+ return False
+ if _contains_custom_type(info):
+ return False
+ try:
+ self._get_msgspec_decoder(model)
+ except Exception: # noqa: BLE001 — can_decode is a probe; any failure means no
+ return False
+ return True
+```
+
+The second `try/except` (decoder-build probe) is retained as defense for any non-`CustomType` shape msgspec still refuses to build.
+
+## Scope boundaries
+
+- **In scope:** the recursive `CustomType` rejection only.
+- **Out of scope (deferred audit nits, by user decision):** the uncached `type_info` call on every dispatch (perf nit a) and `PydanticDecoder` negative-probe caching (perf nit b). This spec does not touch `pydantic.py` and does not add caching to the `can_decode` hot path.
+- **No public-API change:** `can_decode`'s signature and the `ResponseDecoder` protocol are unchanged. Behavior changes only for the previously-mis-accepted nested-CustomType types, which now correctly route to `MissingDecoderError` (or to another registered decoder that claims them — e.g. `PydanticDecoder` claims `list[PUser]`).
+
+## Interaction with multi-decoder routing
+
+Under the default `decoders=[PydanticDecoder(), MsgspecDecoder()]` (pydantic-first), `list[PUser]` already routes to pydantic because pydantic's `can_decode` is consulted first and returns `True`. The bug bites when **only** `MsgspecDecoder` is registered (msgspec-only install, or an explicit `decoders=[MsgspecDecoder()]`): there, `list[PUser]` should raise `MissingDecoderError` at dispatch but instead sends a request and fails at decode. The fix makes the msgspec-only path correct without affecting the pydantic-first default.
+
+## Testing
+
+New tests in `tests/test_decoders_msgspec.py` (the file already defines `_Item` Struct, `_PydanticUser` BaseModel, `_DC` dataclass):
+
+1. **Nested-CustomType rejection** — parametrized over `list[_PydanticUser]`, `dict[str, _PydanticUser]`, `typing.Optional[_PydanticUser]`, `tuple[_PydanticUser, int]`, `list[list[_PydanticUser]]`: each `can_decode(...) is False`.
+2. **Valid containers still accepted** — `list[_Item]`, `dict[str, _Item]`, `dict[str, int]`, `list[int]`, `_Item`, `int`: each `can_decode(...) is True` (guards against over-rejection; `test_msgspec_can_decode_list_of_structs` already covers `list[_Item]`, extend the set).
+3. **Dispatch-level regression** — a `Client` / `AsyncClient` with `decoders=[MsgspecDecoder()]` and `response_model=list[_PydanticUser]` raises `MissingDecoderError` **without** sending a request. Assert the mock transport's handler was never invoked (a flag the handler flips), proving the pre-flight fires. Both sync and async twins.
+4. **Helper unit tests** (optional, if the walker reads better tested directly) — `_contains_custom_type(type_info(list[_PydanticUser])) is True`, `_contains_custom_type(type_info(list[_Item])) is False`.
+
+All four behaviors get sync + async coverage where a client is involved (test #3), per the parity convention.
+
+## Acceptance criteria
+
+1. `MsgspecDecoder().can_decode(list[PUser])` (and the dict/optional/tuple/nested variants) returns `False`.
+2. `MsgspecDecoder().can_decode(list[Struct])`, `can_decode(Struct)`, `can_decode(dict[str, int])` still return `True`.
+3. A msgspec-only client with `response_model=list[PUser]` raises `MissingDecoderError` before any request is sent (transport handler not invoked).
+4. `just lint` and `just test` pass; no `pydantic.py` changes; no public-API or protocol change.
+5. The fix ships as a patch release (0.9.1) per the project's patch-for-bugfix convention.
diff --git a/src/httpware/decoders/msgspec.py b/src/httpware/decoders/msgspec.py
index df14e6a..ca689fb 100644
--- a/src/httpware/decoders/msgspec.py
+++ b/src/httpware/decoders/msgspec.py
@@ -15,6 +15,36 @@
T = TypeVar("T")
+def _contains_custom_type(info: "msgspec.inspect.Type") -> bool:
+ """Return True if `info` is a CustomType or nests one in its parameters.
+
+ Walks generic-container parameterization (list/dict/set/tuple/union element
+ types) by visiting any attribute that is itself a `msgspec.inspect.Type` or a
+ tuple of them. It deliberately does NOT descend into `StructType`/dataclass
+ fields: those expose `fields` as `Field` objects (not `Type`), so the walk
+ stops at the boundary of a type msgspec natively owns. That boundary is what
+ makes the walk both correct (a Struct is a valid target) and safe against
+ infinite recursion on self-referential struct definitions.
+ """
+ if isinstance(info, msgspec.inspect.CustomType):
+ return True
+ for name in dir(info):
+ if name.startswith("_"):
+ continue
+ value = getattr(info, name, None)
+ if isinstance(value, msgspec.inspect.Type):
+ if _contains_custom_type(value):
+ return True
+ elif (
+ isinstance(value, tuple)
+ and value
+ and all(isinstance(item, msgspec.inspect.Type) for item in value)
+ and any(_contains_custom_type(item) for item in value)
+ ):
+ return True
+ return False
+
+
class MsgspecDecoder:
"""Decode raw response bytes via a per-instance cached `msgspec.json.Decoder(model)`.
@@ -38,18 +68,19 @@ def _get_msgspec_decoder(self, model: type[T]) -> "msgspec.json.Decoder[T]":
return decoder
def can_decode(self, model: type) -> bool:
- """Return True iff msgspec natively understands `model`.
+ """Return True iff msgspec natively understands `model` end-to-end.
msgspec builds a Decoder for almost any class via a generic CustomType
- fallback; the Decoder constructor itself does NOT raise on unsupported
- types (e.g. pydantic.BaseModel). We use msgspec.inspect.type_info
- to detect the fallback and reject CustomType results explicitly.
+ fallback; the Decoder constructor does NOT raise on unsupported types
+ (e.g. pydantic.BaseModel, or a container parameterized by one). We walk
+ msgspec.inspect.type_info and reject if a CustomType appears anywhere in
+ the type tree, so MissingDecoderError fires before a request is sent.
"""
try:
info = msgspec.inspect.type_info(model)
except Exception: # noqa: BLE001 — can_decode is a probe; any failure means no
return False
- if isinstance(info, msgspec.inspect.CustomType):
+ if _contains_custom_type(info):
return False
try:
self._get_msgspec_decoder(model)
diff --git a/tests/test_client_dispatch.py b/tests/test_client_dispatch.py
index 398df6a..0aa6538 100644
--- a/tests/test_client_dispatch.py
+++ b/tests/test_client_dispatch.py
@@ -207,3 +207,36 @@ def handler(_: httpx2.Request) -> httpx2.Response: # pragma: no cover
with pytest.raises(MissingDecoderError):
client.get("https://example.test/x", response_model=_PydanticUser)
client.close()
+
+
+async def test_async_msgspec_only_list_of_basemodel_preflight_raises() -> None:
+ """MsgspecDecoder-only client raises MissingDecoderError for list[BaseModel] without sending a request."""
+
+ def handler(_: httpx2.Request) -> httpx2.Response: # pragma: no cover
+ pytest.fail("transport should not be invoked: pre-flight must reject first")
+
+ transport = httpx2.MockTransport(handler)
+ client = AsyncClient(
+ httpx2_client=httpx2.AsyncClient(transport=transport),
+ decoders=[MsgspecDecoder()],
+ )
+ with pytest.raises(MissingDecoderError) as exc_info:
+ await client.get("https://example.test/x", response_model=list[_PydanticUser])
+ assert exc_info.value.registered_names == ("MsgspecDecoder",)
+
+
+def test_sync_msgspec_only_list_of_basemodel_preflight_raises() -> None:
+ """Sync MsgspecDecoder-only client raises MissingDecoderError for list[BaseModel] without sending a request."""
+
+ def handler(_: httpx2.Request) -> httpx2.Response: # pragma: no cover
+ pytest.fail("transport should not be invoked: pre-flight must reject first")
+
+ transport = httpx2.MockTransport(handler)
+ client = Client(
+ httpx2_client=httpx2.Client(transport=transport),
+ decoders=[MsgspecDecoder()],
+ )
+ with pytest.raises(MissingDecoderError) as exc_info:
+ client.get("https://example.test/x", response_model=list[_PydanticUser])
+ assert exc_info.value.registered_names == ("MsgspecDecoder",)
+ client.close()
diff --git a/tests/test_decoders_msgspec.py b/tests/test_decoders_msgspec.py
index 4818159..f81b4ce 100644
--- a/tests/test_decoders_msgspec.py
+++ b/tests/test_decoders_msgspec.py
@@ -152,3 +152,47 @@ def test_unhashable_model_falls_back_to_uncached_decoder() -> None:
):
result = MsgspecDecoder().decode(b"42", int)
assert result == 42 # noqa: PLR2004
+
+
+@pytest.mark.parametrize(
+ "model",
+ [
+ list[_PydanticUser],
+ dict[str, _PydanticUser],
+ dict[_PydanticUser, str],
+ _PydanticUser | None,
+ tuple[_PydanticUser, int],
+ list[list[_PydanticUser]],
+ set[_PydanticUser],
+ ],
+)
+def test_msgspec_rejects_containers_of_pydantic_models(model: type) -> None:
+ """Nested CustomType (a pydantic model inside a container) must be rejected.
+
+ Before the fix, can_decode inspected only the top-level type_info node, so a
+ container parameterized by a BaseModel slipped past and built a decoder via
+ the CustomType fallback — bypassing the MissingDecoderError pre-flight.
+ """
+ assert MsgspecDecoder().can_decode(model) is False
+
+
+@pytest.mark.parametrize(
+ "model",
+ [
+ _Item,
+ list[_Item],
+ dict[str, _Item],
+ list[list[_Item]],
+ dict[str, int],
+ list[int],
+ int,
+ ],
+)
+def test_msgspec_still_accepts_native_containers(model: type) -> None:
+ """Containers parameterized only by msgspec-native types stay accepted.
+
+ Guards against the recursive walker over-rejecting: the walk must stop at
+ Struct boundaries (StructType.fields are Field, not Type) and must not flag
+ plain builtin element types.
+ """
+ assert MsgspecDecoder().can_decode(model) is True