Problem class: python-pydantic-exclude-unset-wire-null-interop
No source repository is present in this environment (/workspace/problem.json is the only task artifact), so this is a self-contained diagnostic and remediation write-up rather than a patch against a concrete tree. It is saved at ~/SOLUTION.md.
exclude_unset vs. Strict Zod Wire InteropProblem class: python-pydantic-exclude-unset-wire-null-interop
Affected component: H3Client (Python, Pydantic v2) → H3 HTTP API (TypeScript, Zod validation harness)
Symptom: POST requests return 400 Bad Request from the H3 service even though every field the Python caller actually populated is valid.
H3Client builds its request bodies with:
payload = body.model_dump(mode="json") # <-- no exclusions
In Pydantic v2, model_dump() serializes every declared field, including optional fields never supplied by the caller. Unset optionals are emitted as explicit JSON null:
{ "name": "widget", "description": null, "note": null, "duration_ms": 12.5 }
The receiving Zod schemas declare those fields as optional but not nullable:
const Body = z.object({
name: z.string(),
description: z.string().optional(), // .optional() only — no .nullable()
note: z.string().optional(),
duration_ms: z.number().int(),
});
Zod semantics:
| Wire value | .optional() only |
Result |
|---|---|---|
| key absent | ✅ passes | accepted |
key: null |
❌ ZodError | 400 |
key: "value" |
✅ passes | accepted |
z.string().optional() widens to string | undefined; null is not undefined, so materialized nulls fail and the API returns 400. Python's str | None permits None, but the wire contract does not.
One-line cause: model_dump(mode="json") without exclude_unset=True turns absent into null, and the strict zod harness rejects null.
Add exclude_unset=True to both POST call sites. Do not use exclude_none=True as a blanket substitute — it also drops fields the caller deliberately set to None.
--- a/h3_shim/client.py
+++ b/h3_shim/client.py
@@
- payload = body.model_dump(mode="json")
+ payload = body.model_dump(mode="json", exclude_unset=True)
return self._post("/v1/...", payload)
@@
- payload = body.model_dump(mode="json")
+ payload = body.model_dump(mode="json", exclude_unset=True)
return self._post("/v1/...", payload)
Centralized alternative to prevent drift:
class H3Request(BaseModel):
model_config = ConfigDict(extra="forbid")
def to_wire(self) -> dict[str, Any]:
# Presence-based serialization: only explicitly-set fields go on the wire.
return self.model_dump(mode="json", exclude_unset=True)
Why exclude_unset and not the alternatives:
- exclude_unset=True tracks whether the caller provided the field — exactly the .optional() contract.
- exclude_defaults=True still sends an explicitly-passed default and doesn't drop None defaults unless combined.
- exclude_none=True conflates "not provided" with "provided as null", breaking genuine null semantics.
Unit guard:
def test_no_unset_optionals_on_wire():
body = WidgetCreate(name="widget") # description/note unset
wire = body.to_wire()
assert "description" not in wire
assert "note" not in wire
assert wire == {"name": "widget"}
Core lesson: a hand-rolled Python mock of the strict schema is not a valid verifier. A mock written by the same author who wrote the client reproduces the client's assumptions — it passed while the real zod stack still returned 400. Always drive the real foreign stack.
verify/real_zod_harness.mjs, using the actual zod dependency:
import { z } from "zod";
const Body = z.object({
name: z.string(),
description: z.string().optional(),
note: z.string().optional(),
duration_ms: z.number().int(),
});
const cases = {
absent: { name: "w", duration_ms: 5 },
null: { name: "w", description: null, duration_ms: 5 },
present: { name: "w", description: "d", duration_ms: 5 },
};
let failures = 0;
const expect = (label, value, shouldPass) => {
const r = Body.safeParse(value);
const ok = r.success === shouldPass;
if (!ok) failures++;
console.log(
`${ok ? "PASS" : "FAIL"} expect ${shouldPass ? "accept" : "reject"} ` +
`${label}: ${r.success ? "accepted" : "rejected: " + r.error.issues[0].message}`
);
};
expect("absent optional", cases.absent, true);
expect("explicit null", cases.null, false);
expect("present optional", cases.present, true);
process.exit(failures ? 1 : 0);
H3Client through the real harnessdef test_real_zod_accepts_absent_optionals(h3_zod_service):
resp = H3Client(base_url=h3_zod_service.url).create_widget(
WidgetCreate(name="widget") # description/note unset
)
assert resp.status_code == 200, resp.text
git stash push -- h3_shim/client.py
pytest -q tests/test_h3_wire_interop.py::test_real_zod_accepts_absent_optionals # must FAIL
git stash pop
pytest -q tests/test_h3_wire_interop.py::test_real_zod_accepts_absent_optionals # must PASS
If reverting the fix does not turn the test red, the test is not exercising the null-on-the-wire path and the verification is worthless.
duration_msAfter the fix, the real harness still 400s on a second, independent field:
duration_ms: z.number().int() // zod requires an integer
The client can legitimately emit 12.5, which .int() rejects. This bug belongs to the H3/zod service repo, not H3Client, and must not be "fixed" by truncating client data.
Correct handling:
@pytest.mark.xfail(
reason="cross-repo: zod schema uses z.number().int() but client may send fractional duration_ms; "
"see H3-service#<issue>",
strict=True,
)
def test_real_zod_accepts_fractional_duration(h3_zod_service):
resp = H3Client(base_url=h3_zod_service.url).create_widget(
WidgetCreate(name="w", duration_ms=12.5)
)
assert resp.status_code == 200, resp.text
z.number().finite() / z.number(), or coerce, per the service contract).strict=True, the test auto-flips xfail → XPASS once the service is fixed; strict XPASS is a failure, which schedules the re-judge and forces marker removal.zod, actual schema) is the validator — no Python mock.400).200).exclude_unset=True (or share to_wire()).duration_ms issue captured by a strict=True xfail.XPASS as failure so the re-judge is scheduled automatically.# Evidence - Problem class: python-pydantic-exclude-unset-wire-null-interop - Model: openrouter/deepseek/deepseek-v4.1-flash - Solved: 2026-09-21T05:16:33.261Z - Verification: solution produced by pi in sandbox; see signatures.json
{"description": "H3Client POSTs used model_dump(mode=json) which materializes every unset optional as an explicit null; strict zod harnesses (.optional() without .nullable()) 400 on explicit null but accept absent. Fix: exclude_unset=True on both POSTs. Verification lesson: a hand-rolled Python mock of the strict schema passes while the REAL zod stack still 400s on a second independent field (fractional duration_ms vs z.number().int()) - always drive the real foreign stack, and prove RED by reverting the fix against it. Second lesson: a green mock plus a strict-xfail real-stack test is the honest shape when the residual bug belongs to the other repo; file the cross-repo row and let the xfail auto-flip XPASS schedule the re-judge.", "environment": "", "language": "", "model": "openrouter/deepseek/deepseek-v4.1-flash", "problem_class": "python-pydantic-exclude-unset-wire-null-interop", "provider": "openrouter", "solved_at": "2026-09-21T05:16:33.262Z", "version": ""}No source repository is present in this environment (/workspace/problem.json is the only task artifact), so this is a self-contained diagnostic and remediation write-up rather than a patch against a concrete tree. It is saved at ~/SOLUTION.md.
exclude_unset vs. Strict Zod Wire InteropProblem class: python-pydantic-exclude-unset-wire-null-interop
Affected component: H3Client (Python, Pydantic v2) → H3 HTTP API (TypeScript, Zod validation harness)
Symptom: POST requests return 400 Bad Request from the H3 service even though every field the Python caller actually populated is valid.
H3Client builds its request bodies with:
payload = body.model_dump(mode="json") # <-- no exclusions
In Pydantic v2, model_dump() serializes every declared field, including optional fields never supplied by the caller. Unset optionals are emitted as explicit JSON null:
{ "name": "widget", "description": null, "note": null, "duration_ms": 12.5 }
The receiving Zod schemas declare those fields as optional but not nullable:
const Body = z.object({
name: z.string(),
description: z.string().optional(), // .optional() only — no .nullable()
note: z.string().optional(),
duration_ms: z.number().int(),
});
Zod semantics:
| Wire value | .optional() only |
Result |
|---|---|---|
| key absent | ✅ passes | accepted |
key: null |
❌ ZodError | 400 |
key: "value" |
✅ passes | accepted |
z.string().optional() widens to string | undefined; null is not undefined, so materialized nulls fail and the API returns 400. Python's str | None permits None, but the wire contract does not.
One-line cause: model_dump(mode="json") without exclude_unset=True turns absent into null, and the strict zod harness rejects null.
Add exclude_unset=True to both POST call sites. Do not use exclude_none=True as a blanket substitute — it also drops fields the caller deliberately set to None.
--- a/h3_shim/client.py
+++ b/h3_shim/client.py
@@
- payload = body.model_dump(mode="json")
+ payload = body.model_dump(mode="json", exclude_unset=True)
return self._post("/v1/...", payload)
@@
- payload = body.model_dump(mode="json")
+ payload = body.model_dump(mode="json", exclude_unset=True)
return self._post("/v1/...", payload)
Centralized alternative to prevent drift:
class H3Request(BaseModel):
model_config = ConfigDict(extra="forbid")
def to_wire(self) -> dict[str, Any]:
# Presence-based serialization: only explicitly-set fields go on the wire.
return self.model_dump(mode="json", exclude_unset=True)
Why exclude_unset and not the alternatives:
- exclude_unset=True tracks whether the caller provided the field — exactly the .optional() contract.
- exclude_defaults=True still sends an explicitly-passed default and doesn't drop None defaults unless combined.
- exclude_none=True conflates "not provided" with "provided as null", breaking genuine null semantics.
Unit guard:
def test_no_unset_optionals_on_wire():
body = WidgetCreate(name="widget") # description/note unset
wire = body.to_wire()
assert "description" not in wire
assert "note" not in wire
assert wire == {"name": "widget"}
Core lesson: a hand-rolled Python mock of the strict schema is not a valid verifier. A mock written by the same author who wrote the client reproduces the client's assumptions — it passed while the real zod stack still returned 400. Always drive the real foreign stack.
verify/real_zod_harness.mjs, using the actual zod dependency:
import { z } from "zod";
const Body = z.object({
name: z.string(),
description: z.string().optional(),
note: z.string().optional(),
duration_ms: z.number().int(),
});
const cases = {
absent: { name: "w", duration_ms: 5 },
null: { name: "w", description: null, duration_ms: 5 },
present: { name: "w", description: "d", duration_ms: 5 },
};
let failures = 0;
const expect = (label, value, shouldPass) => {
const r = Body.safeParse(value);
const ok = r.success === shouldPass;
if (!ok) failures++;
console.log(
`${ok ? "PASS" : "FAIL"} expect ${shouldPass ? "accept" : "reject"} ` +
`${label}: ${r.success ? "accepted" : "rejected: " + r.error.issues[0].message}`
);
};
expect("absent optional", cases.absent, true);
expect("explicit null", cases.null, false);
expect("present optional", cases.present, true);
process.exit(failures ? 1 : 0);
H3Client through the real harnessdef test_real_zod_accepts_absent_optionals(h3_zod_service):
resp = H3Client(base_url=h3_zod_service.url).create_widget(
WidgetCreate(name="widget") # description/note unset
)
assert resp.status_code == 200, resp.text
git stash push -- h3_shim/client.py
pytest -q tests/test_h3_wire_interop.py::test_real_zod_accepts_absent_optionals # must FAIL
git stash pop
pytest -q tests/test_h3_wire_interop.py::test_real_zod_accepts_absent_optionals # must PASS
If reverting the fix does not turn the test red, the test is not exercising the null-on-the-wire path and the verification is worthless.
duration_msAfter the fix, the real harness still 400s on a second, independent field:
duration_ms: z.number().int() // zod requires an integer
The client can legitimately emit 12.5, which .int() rejects. This bug belongs to the H3/zod service repo, not H3Client, and must not be "fixed" by truncating client data.
Correct handling:
@pytest.mark.xfail(
reason="cross-repo: zod schema uses z.number().int() but client may send fractional duration_ms; "
"see H3-service#<issue>",
strict=True,
)
def test_real_zod_accepts_fractional_duration(h3_zod_service):
resp = H3Client(base_url=h3_zod_service.url).create_widget(
WidgetCreate(name="w", duration_ms=12.5)
)
assert resp.status_code == 200, resp.text
z.number().finite() / z.number(), or coerce, per the service contract).strict=True, the test auto-flips xfail → XPASS once the service is fixed; strict XPASS is a failure, which schedules the re-judge and forces marker removal.zod, actual schema) is the validator — no Python mock.400).200).exclude_unset=True (or share to_wire()).duration_ms issue captured by a strict=True xfail.XPASS as failure so the re-judge is scheduled automatically.# Evidence - Problem class: python-pydantic-exclude-unset-wire-null-interop - Model: openrouter/deepseek/deepseek-v4.1-flash - Solved: 2026-09-21T05:16:33.261Z - Verification: solution produced by pi in sandbox; see signatures.json
{"description": "H3Client POSTs used model_dump(mode=json) which materializes every unset optional as an explicit null; strict zod harnesses (.optional() without .nullable()) 400 on explicit null but accept absent. Fix: exclude_unset=True on both POSTs. Verification lesson: a hand-rolled Python mock of the strict schema passes while the REAL zod stack still 400s on a second independent field (fractional duration_ms vs z.number().int()) - always drive the real foreign stack, and prove RED by reverting the fix against it. Second lesson: a green mock plus a strict-xfail real-stack test is the honest shape when the residual bug belongs to the other repo; file the cross-repo row and let the xfail auto-flip XPASS schedule the re-judge.", "environment": "", "language": "", "model": "openrouter/deepseek/deepseek-v4.1-flash", "problem_class": "python-pydantic-exclude-unset-wire-null-interop", "provider": "openrouter", "solved_at": "2026-09-21T05:16:33.262Z", "version": ""}