From d7150d0cb07ffa1e382aabe393dd568c3a0acc78 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Fri, 12 Jun 2026 14:11:01 +0300 Subject: [PATCH 1/9] docs(spec): 0.9.0 delta audit design Co-Authored-By: Claude Fable 5 --- .../specs/2026-06-12-delta-audit-design.md | 135 ++++++++++++++++++ 1 file changed, 135 insertions(+) create mode 100644 planning/specs/2026-06-12-delta-audit-design.md 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. From a7cfbfda03834d7465118a9f51f68b5e39404005 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Fri, 12 Jun 2026 14:15:45 +0300 Subject: [PATCH 2/9] docs(plan): 0.9.0 delta audit implementation plan Co-Authored-By: Claude Fable 5 --- planning/plans/2026-06-12-delta-audit-plan.md | 533 ++++++++++++++++++ 1 file changed, 533 insertions(+) create mode 100644 planning/plans/2026-06-12-delta-audit-plan.md 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. From f8bb0e5cafa602b9b293fef8a63ccfc33c07f181 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Sat, 13 Jun 2026 09:07:44 +0300 Subject: [PATCH 3/9] audit(delta): add 0.9.0 delta audit workflow script Co-Authored-By: Claude Fable 5 --- planning/audit/workflow-delta.mjs | 427 ++++++++++++++++++++++++++++++ 1 file changed, 427 insertions(+) create mode 100644 planning/audit/workflow-delta.mjs 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 +} From 38eb9e81d2d5607b1eb5ec222b58ee83b0766699 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Sat, 13 Jun 2026 09:24:19 +0300 Subject: [PATCH 4/9] audit(delta): 0.9.0 multi-decoder delta audit findings Co-Authored-By: Claude Fable 5 --- planning/audit/2026-06-12-delta-audit.md | 359 +++++++++++++++++++++++ 1 file changed, 359 insertions(+) create mode 100644 planning/audit/2026-06-12-delta-audit.md 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. From c6f81c2b012f9c0c095dd581b0892aac46c41f73 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Sat, 13 Jun 2026 10:03:53 +0300 Subject: [PATCH 5/9] docs(spec): fix MsgspecDecoder.can_decode nested CustomType false-positive Co-Authored-By: Claude Fable 5 --- ...13-msgspec-nested-customtype-fix-design.md | 129 ++++++++++++++++++ 1 file changed, 129 insertions(+) create mode 100644 planning/specs/2026-06-13-msgspec-nested-customtype-fix-design.md 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. From 788432e42c4cc87ce6868af927f20284a9ead4b2 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Sat, 13 Jun 2026 10:08:39 +0300 Subject: [PATCH 6/9] docs(plan): msgspec nested CustomType fix implementation plan Co-Authored-By: Claude Fable 5 --- ...6-13-msgspec-nested-customtype-fix-plan.md | 315 ++++++++++++++++++ 1 file changed, 315 insertions(+) create mode 100644 planning/plans/2026-06-13-msgspec-nested-customtype-fix-plan.md 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. From 0ea483124dcf85f7fbf74d7562cb6c23d58987de Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Sat, 13 Jun 2026 10:11:57 +0300 Subject: [PATCH 7/9] 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 --- src/httpware/decoders/msgspec.py | 41 +++++++++++++++++++++++++---- tests/test_decoders_msgspec.py | 44 ++++++++++++++++++++++++++++++++ 2 files changed, 80 insertions(+), 5 deletions(-) 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_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 From 6bb69910c8386a7c30b4a5cb180cdea18e78504c Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Sat, 13 Jun 2026 10:20:27 +0300 Subject: [PATCH 8/9] 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 --- tests/test_client_dispatch.py | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) 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() From 898fac88c2871c66162567187b7b1155c458c3f3 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Sat, 13 Jun 2026 10:25:33 +0300 Subject: [PATCH 9/9] =?UTF-8?q?docs(release):=20draft=200.9.1=20notes=20?= =?UTF-8?q?=E2=80=94=20msgspec=20nested-CustomType=20fix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- planning/releases/0.9.1.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 planning/releases/0.9.1.md 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.