Repository navigation
feat(types): keep wire bytes on parsed URLs, datetimes, UUIDs and emails so digests round-trip - #1617
Conversation
There was a problem hiding this comment.
Ladon verdict: Approve
Approve — clean, well-tested fix for #1610 with no blocking findings.
What I checked:
- The
high_riskflag is true only because two existing type files were modified (src/adcp/types/__init__.py,src/adcp/types/_eager.py), both(modified)— not deleted. No medium-or-higher findings on either, so the modification is presumed safe (no escalation on the flag alone). gated_paths: false, so despitereview_decision: REVIEW_REQUIRED, the hard path gate (row 2) does not apply.- No author no-auto-approve team match.
- No prior decision (first review).
Findings: Reviewer reports zero Critical/High/Medium findings. Wire-preserving scalar types make parsed URL/datetime/UUID/email dump as received for digest round-tripping; the 301 generated-file changes are mechanical tokenizer-based type renames (AwareDatetime→WireDatetime), not hand-edits — codegen churn, not a semantic schema delta. Repo floors hold: TYPE_CHECKING alias keeps the type-checker surface unchanged, import layering respected, no CI gates disabled, no new wire vocabulary.
One non-blocking observation (WireAnyUrl at L165 relies on inherited AnyUrl serializer rather than pinning its own) is test-guarded by test_every_url_spelling_survives, so any regression fails CI rather than shipping silently. Not a finding.
Decision table: rows 1–8 do not fire (no critical/high, gated_paths false, no deletions, no medium findings, no team gate, no prior escalation). Falls through to row 9 → approve.
There was a problem hiding this comment.
Ladon verdict: Approve
Approve — subsequent pass with no findings; test hygiene only.
This is a follow-up to a prior approve. The delta since then is two conformance test files:
_round_trip.pyadds a side-effectimport adcp.types._eager(correctlynoqa: F401) so import-time patchers run before grading, removing test-order non-determinism and grading the adopter-facing models.test_wire_round_trip.pyrecordscore/publisher-property-selector.jsonin the shrink-onlyREFUSINGset; verified not inDIGEST_COVERED/ECHO_COVERED, so no bound-object assertion is suppressed; the refusal originates in a pre-existing_ergonomicmutual-exclusivity validator outside this PR.
Decision-table walk:
- Rows 1 (critical/high): no findings — skip.
- Row 2 (gated_paths): false — skip.
- Row 3 (deleted high-risk): reasons are
(modified)only — skip. - Row 4 (medium data-loss/schema/infra): no findings — skip.
- Row 5 (high_risk modified + medium): high_risk is true with
(modified)entries, but there are zero medium findings — does not fire. - Row 6 (sticky escalate): prior decision was
approve, notescalate— skip. - Row 7 (no-auto-approve team): no match — skip.
- Row 8 (≥3 mediums): zero mediums — skip.
- Row 9: approve.
The high_risk flag on types/__init__.py and _eager.py is a heuristic LOOK signal; both are (modified) with no medium-or-higher concerns, so presumed safe. Clean iteration.
There was a problem hiding this comment.
Ladon verdict: Approve
Approve.
The reviewer found no critical, high, or medium findings. The sole delta is a defensive fix in src/adcp/types/_wire.py: WireDatetime.wire (L112) and __reduce_ex__ (L120) now read the _wire slot via getattr(self, "_wire", None) instead of a bare attribute access. This correctly handles the Python 3.10/3.11 case where C-level datetime.replace()/astimezone()/arithmetic builds the subclass without calling __new__, leaving the __slots__ member unset — a bare self._wire would AttributeError on .wire access and on pickling a derived value. The getattr fall-through to rfc3339(self) is the documented normalized-form behavior and mirrors the existing WireAnyUrl/WireUuid pattern. Remaining _wire touches are writes, which are slot-safe.
Decision-table walk: no critical/high (row 1 n/a); gated_paths false (row 2 n/a); no (deleted) high-risk reasons — only (modified) on two type files (row 3 n/a); no medium findings at all (rows 4, 5, 8 n/a); prior decision was approve, not escalate (row 6 n/a); no no-auto-approve team match (row 7 n/a). The high_risk flag is a heuristic only — the two modified type files carry no medium-or-higher concern, so the modification is presumed safe. Falls through to row 9.
There was a problem hiding this comment.
Ladon verdict: Approve
Approve.
Clean pass. The delta tightens wire-text preservation to strict-only forms via a new cycle-free leaf module adcp._formats, shared by schema_loader and the WireAnyUrl/WireDatetime/WireUuid/WireEmail types. The reviewer verified the str()/.host security guarantee (fast is_plain_uri subset grammar + slow-path explicit host comparison with a tested backslash fallback), single-source-of-truth consistency (is_plain_uri ⟹ is_uri_syntax), import safety (unused date/quote removed; _formats is a cycle-free leaf), pickle/deepcopy slot handling, and alignment with docs and new parametrized tests.
No Critical/High/Medium findings. Decision-table walk:
- Row 1 (critical/high): no such findings.
- Row 2 (gated_paths unsatisfied): gated_paths is false — not triggered.
- Row 3/5 (high-risk deleted/modified + medium): both
_eager.pyandtypes/__init__.pyare(modified), but there are no medium findings, so neither fires. - Row 4 (medium data-loss/schema/infra): none.
- Row 6 (sticky escalate): prior decision was
approve, not applicable. - Row 7 (no-auto-approve team): no match.
- Row 8 (≥3 medium): zero medium findings.
- Row 9: approve.
Prior decision was also approve on an earlier head; this is a net-clean subsequent pass. high_risk is true only via (modified) entries with no accompanying findings, which is presumed safe per the high-risk reading rules.
…ls so digests round-trip A generated model parsed `baseline.system` into pydantic `AnyUrl` (which re-serializes a bare origin with a trailing slash) and `last_updated` into `AwareDatetime` (which rewrites `+00:00` as `Z` and drops `.000`), so a parsed-then-dumped `AudienceEvidence` no longer matched its own `content_digest`. The same gap had been patched one field or object at a time in #1384, #1449, #1587 and #1575. Invariant: a value the model did not change is dumped as it was received. - `adcp.types._wire`: `WireDatetime`, `WireAnyUrl`, `WireUuid`, `WireEmail` validate as the pydantic types they replace, keep the attribute API and JSON Schema, and serialize the text they were parsed from; a value built in Python serializes in the documented deterministic form. To a type checker the re-exported names are the pydantic types, so adopters' verdicts are unchanged. - `post_generate_fixes.preserve_wire_text_on_normalizing_fields` points every generated `format: date-time` / `uri` / `uuid` / `email` field at them (token-level rename; descriptions untouched). - `AdCPBaseModel.model_dump_as_received()`: the explicit "as received" dump (`exclude_unset`, nulls kept, `by_alias`) that a digest verifier uses until the default dump stops materializing schema defaults (#1613). - `tests/conformance/test_wire_round_trip.py`: a permanent sweep of every bundle schema with a model, under JCS, with a shrink-only allowlist for the remaining classes (#1613, #1614, #1615) and none for digest- or echo-covered objects on the as-received path. - `AudienceEvidence` and its pin/selection siblings are exported from `adcp.types`. - `docs/validation-contract.md`: root-cause analysis and the invariant. Fixes #1610 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…-trip sweep The verdict for a schema depended on whether adcp.types._eager had already run its import-time patchers: CI's full session refused a PublisherPropertySelector document that an isolated run accepted. The sweep now imports the eager surface up front and declares that schema as refusing. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
On Python 3.10 and 3.11, datetime.replace(), astimezone() and arithmetic build the subclass instance without calling the overriding __new__, so the _wire slot was unset on a derived value and .wire raised AttributeError. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The validators saved any str input, so a spelling pydantic's lenient parser accepts but the schema rejects (a space instead of T, an offset without a colon, an epoch string, surrounding whitespace, a braced UUID, a display-name email) was echoed verbatim where main normalized it. The strict date-time and URI checks now live in adcp._formats, shared with schema_loader; a UUID must be the hyphenated form; an email is kept only as the trimmed address. str() and .host of a URL can no longer disagree: text is kept only when its own authority names the parsed host, so https://good.example\@evil.example falls back to the normalized form. Also cheaper: datetime/UUID serializers run in JSON mode only, the datetime is built from its packed state in one C call, the URL validator is one Python layer around the core parser, and a fast RFC 3986 subset grammar settles the common URI before the full validator runs. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
6004a56 to
d91e8e3
Compare
There was a problem hiding this comment.
Ladon verdict: Approve
Approve — clean delta pass over three follow-up commits, no blocking or medium findings.
What I checked:
- No author no-auto-approve team match;
gated_paths: false(row 2 not triggered despitereview_decision: REVIEW_REQUIRED). high_risk: truebut all reasons are(modified)with NO medium-or-higher findings, so row 5 does not fire; no(deleted)entries (row 3 clear).- Prior decision was
approve(not escalate), so sticky row 6 is irrelevant. - Reviewer confirms the
_wire.pystrict-format gate is sound:_keep_url_textfast path is safe because the subset grammar admits only[A-Za-z0-9.\-]+authorities (no@,\,%,[), with a full RFC 3986 + host comparison on the normalized arm keeping thegood.example\@evil.examplespoof contained. Fail-toward-normalize applied consistently for datetime/UUID/email.WireDatetime.__new__drop verified safe. No new public-surface, import-layering, generated-code, union, or credential concerns.
No Critical/High/Medium findings → falls through rows 1–8 to row 9 (approve).
…newline is_plain_uri used match with a trailing $, which also accepts a final newline, and the fast path kept the text without comparing hosts, so a shorthand IPv4 host (2130706433, 0x7f.1, 127.1) kept its spelling while .host read 127.0.0.1. Use fullmatch and compare hosts on every path. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
|
Pushed the two fixes from the re-review:
New tests for both fail without the fix. The wire tests pass (854). Perf for the record: on a 2,000-product |
| """Whether ``text`` is a URI the fast subset grammar accepts. | ||
|
|
||
| True implies :func:`is_uri_syntax` is true and that the host the text | ||
| names is the host a URL parser reports (lower-cased). The common case, |
There was a problem hiding this comment.
Medium: This docstring still claims is_plain_uri True "implies ... the host the text names is the host a URL parser reports (lower-cased)." The delta disproves exactly that: https://2130706433/ passes _PLAIN_URI (host class [A-Za-z0-9.\-]+), yet the WHATWG parser rewrites it to 127.0.0.1 — which is why _keep_url_text just dropped its is_plain_uri fast-echo path in favor of comparing hosts on every path. _wire.py:250-251 carries the same stale claim ("the fast grammar ... guarantees the host agrees"). Load-bearing SSRF-adjacent invariant: a maintainer trusting "guarantees the host agrees" could re-add the removed fast path and reintroduce the host/echo mismatch. Correct the two docstrings to match the code.
There was a problem hiding this comment.
Ladon verdict: Approve
Approve — clean security tightening with one non-blocking medium.
This PR hardens the private URL-wire layer: is_plain_uri switches match→fullmatch (closing a trailing-newline echo bypass), and _keep_url_text drops the fast-echo path so hosts are compared on every path (keeping str() consistent with .host for shorthand-IPv4 like 2130706433 → 127.0.0.1, preserving an SSRF-guard invariant). Both changes are correct and covered by three new test cases. Both modules are private, so there is no public-surface or semver impact.
Medium findings
- src/adcp/_formats.py:105 — Stale security invariant in
is_plain_uri/_keep_url_textdocstrings (docstrings still assert the now-false guarantee that the fast grammar implies host agreement).
Decision rationale: No critical/high findings (row 1 N/A). gated_paths is false (row 2 N/A). High-risk reasons are (modified) only, no (deleted) (row 3 N/A). The single medium is a docstring-accuracy issue, not data-loss/schema/infra (row 4 N/A). Row 5 requires a (modified) high-risk file plus a medium finding — but the medium is in src/adcp/_formats.py, which is NOT one of the high-risk-flagged files (types/__init__.py, types/_eager.py); the high-risk-modified files carry no medium finding, so row 5 does not fire. No author team gate (row 7 N/A). Only one medium finding, well under three (row 8 N/A). Prior decision was approve, so sticky escalation (row 6) does not apply. Falls through to row 9 → approve.
Medium findings
- src/adcp/_formats.py:105 — Stale security invariant in is_plain_uri / _keep_url_text docstrings
Fixes #1610.
The defect
core/audience-evidence.jsondefinescontent_digestas SHA-256 over the RFC 8785 (JCS) form of the entry as declared. The generatedAudienceEvidenceparsedbaseline.system,methodology_urland attestation origins as pydanticAnyUrl, which re-serializes a bare origin with a trailing slash, andlast_updatedasAwareDatetime, which rewrites+00:00asZand drops.000. A parsed-then-dumped entry no longer matched its own digest: a seller validating with the SDK published digests buyers could not verify, and a buyer parsing with the SDK could not verify a valid one.Root cause
The SDK had no invariant that a schema-valid wire document survives parse → dump unchanged, and several mechanisms rewrite documents silently:
formatto the pydantic type that parses it best (AnyUrl,AwareDatetime,UUID,EmailStr), and those types serialize the value they parsed, not the text they were given.model_dumpemits every field, so a schemadefaultthe generator wrote into the field appears though the document omitted it;exclude_none(the default) drops an explicitnullthe schema admits;by_aliasis off, so$schemadumps asfield_schema;extra='ignore'drops a property the pinned model does not declare.Each is reasonable for an application type and wrong for an object whose bytes are an identity. Four earlier fixes were point patches of this one gap: #1384 typed one field (
agent_url) as a wire string because an option ID hashed it; #1449 (item 3) reported that dumping a validatedProductFormatDeclaration"adds every branch default to the published format"; #1587 made nested models leave unset fields off from any parent; #1575 kept aContextObject's null keys. #1610 is the next instance.The invariant that prevents a recurrence: a value the model did not change is dumped as it was received. Two mechanisms carry it (below), and a conformance suite measures it for every bundle schema, so a regenerated schema or a new normalizing type cannot reintroduce the class silently. The full write-up is in
docs/validation-contract.md("Wire round trip").Sweep
tests/conformance/_round_trip.pytakes schema-valid documents for every bundle schema that has a model (the bundle'sexamples, completed over a synthesized base where they are fragments; a synthesized document per schema; the differential fixtures; a checked-in corpus for the digest-covered schemas the instantiator cannot derive), derives variants exercising each form a normalizing parser rewrites (bare origin, mixed-case host and scheme, default port, percent-encoding;+00:00with.000, a negative offset,.5, nanoseconds, lower-caset/z; upper-case UUID; mixed-case email), runs each throughModel.model_validate_json(doc).model_dump(mode="json")with the SDK's defaults, and compares to the input under JCS. 6,085 documents graded across 999 base documents and 766 schemas.python -m tests.conformance._round_trip --variantsprints the report.Distinct schemas affected per class, before and after this PR, under the default dump:
url_normalizedAnyUrlre-serializes (trailing slash, host/scheme case, default port)baseline.system, issuerorigin,evaluated_by, …)datetime_reformattedAwareDatetimewritesZ, drops.000, truncates nanosecondslast_updated,evaluated_at,expires_at,validity_hint, …email_normalizedEmailStrlower-cases the domainprovider.data_subject_contestation.emailviabrand-refuuid_normalizedUUIDlower-casesdefault_emittedcommercial_terms→terms_digest(6 defaults reachable),package-format-snapshot(267),check-governance-request(7). Filed #1613.field_droppedextra='ignore'/ a union arm narrower than the schema drops a propertyalias_emittedmodel_dump()writespass_,field_schema,from_,media_buyby_alias. Filed #1615.null_droppedexclude_nonedrops an admitted explicitnulltargeting_overlay.signal_targeting_groups; not digest-covered. Filed #1615.Under
model_dump_as_received()(below) the residue isfield_droppedonly. Digest- and echo-covered schemas: no difference of any class on that path, in every form.context/extround-trip their nested nulls on both paths.Also found: 40 schema-valid documents (
core/mcp-webhook-payload.json's four bundle examples,core/async-response-data.json) make the model raiseTypeErrorfrom amin_lengthapplied to aLossesmodel instead of validating. Filed #1616; the suite records refusing schemas as a shrink-only set.Inventory of digest- and signature-covered objects (pin 3.2.4)
Read off the bundle descriptions; listed in
_round_trip.DIGEST_COVEREDwith the digest each declares:core/audience-evidence.json(content_digestminuscontent_digest,attestation_refs),core/canonical-audience-evidence.json,core/audience-evidence-pin.json,core/audience-evidence-selection.json,core/canonical-audience-evidence-selection.json(the same digest, repeated and matched),core/rights-constraint.json(minuscontent_digest,attestation_refs,verification_url),core/rights-attestation-evaluation.json,core/creative-representation-set.json(revision_content_digest),core/representation-selection.json+core/creative-manifest.json(selected_output_digestover the derived manifest),media-buy/acceptance-policy-profile.json(content_digest),core/canonical-proposal.json+media-buy/commercial-terms.json(terms_digest),core/package-format-snapshot.json(tracker_execution_contract_digest,product_snapshot_digest),core/attestation-reference.json+core/attestation-evaluation.json(reference_digest),governance/check-governance-request.json(statement_digest),governance/check-governance-response.json(runtime_attestation_binding_digest),governance/get-plan-audit-logs-response.json(plan_hash). The reportingcanonical_content_digestfamily is computed byadcp.reporting.canonical_jsonover logical rows, not over the carrying model's fields, so it is not affected. Echo-covered:core/context.json,core/ext.json.Normalizing fields inside those subtrees (from the schema graph):
audience-evidencereaches 44uri, 23date-time, 4email, 2datefields;rights-constraint39/24/4;creative-representation-set57/39/5;check-governance-request69/46/6.datefields do not rewrite for schema-valid input (YYYY-MM-DDis the only form). Floats do not rewrite under JCS (1.0and1canonicalize alike); enums dump their value.The fix
1. Wire-preserving scalar types, applied by a generator rule.
src/adcp/types/_wire.pydefinesWireDatetime(datetime),WireAnyUrl(AnyUrl),WireUuid(UUID)andWireEmail(a validatedstr). The text is kept only when it passes the strict wire check the bundled schema validator applies (adcp._formats, shared withschema_loader): RFC 3339 for a date-time, RFC 3986 syntax for a URI whose text names the host the parser reports, the hyphenated 8-4-4-4-12 form for a UUID, and for an email the trimmed address itself (case-insensitively equal to the normalized one). A string pydantic's lenient parser accepts but the schema rejects —"2026-10-01 00:00:00+00:00","...+0000","...T00:00Z", an epoch string," https://x.example ","https:x.example","https://x.example/a b", a braced orurn:uuid:UUID,"Bob <bob@example.com>"— is normalized exactly as main did, so nothing invalid is ever echoed.str(url)and.hostcannot disagree:"https://good.example\\@evil.example"(a backslash the WHATWG parser reads as a slash) falls back tohttps://good.example/@evil.example, as on main; a strict text whose host differs from the parsed host is not kept either. Each validates exactly as the pydantic type did (same core schema, same errors), keeps the attribute API (.year,.tzinfo,.host,.path,.hex), compares and hashes as the same value (WireAnyUrl == AnyUrlholds both ways), survivesdeepcopy/pickle/model_copy(deep=True)/revalidation, advertises the same JSON Schema (format: date-time/uri/uuid/email), and in JSON mode serializes the text it was parsed from — including underserialize_as_any=True, whichAdCPBaseModel.model_dumppasses and which otherwise bypasses the field serializer (a class-level__pydantic_serializer__, the same deviceAnyUrluses).scripts/post_generate_fixes.py::preserve_wire_text_on_normalizing_fieldsrenames the identifier tokensAwareDatetime/AnyUrl/UUID/EmailStrin every generated module (tokenizer-based, so a description that says "UUID v4" is untouched) and rewrites the imports; the effect manifest records it. 301 generated files change, every change a type rename or import line (checked: no renumber churn).make validate-generatedpasses.Behaviour change, stated plainly. For every parsed
format: uri,date-time,uuidandemailfield in the ~300 generated modules, JSON output andstr()now carry the spelling received instead of pydantic's normalized one (https://x.examplestays without its trailing slash;+00:00is not rewritten asZ; an upper-case UUID or email domain keeps its case).type(x) is datetime/is AnyUrl/is UUIDis now false for a parsed value whileisinstancestill holds. A cache or map keyed bystr(url)sees a different spelling for the same URL than before (https://x.examplevshttps://x.example/); key byurl.unicode_string()orurl.hostfor a normalized key. Values built from Python objects are unaffected.Non-breaking for 9.x. At runtime every attribute is still a
datetime/AnyUrl/UUID. To a type checker the names re-exported fromadcp.types.baseareAwareDatetime/AnyUrl/UUID(aTYPE_CHECKINGalias), so an adopter'sassert_type, assignment, or constructor call keeps its verdict — the adopter type-check suite (make typecheck-all, mypy strict + pyright) passes unchanged.wire_text(value)is the typed way to read the received text;.wireis a runtime property.Constructed values (a Python
datetimepassed in rather than parsed) serialize exactly as before: RFC 3339 withZfor UTC and±HH:MMotherwise, fractional seconds only when non-zero and then six digits; pydantic's normalized URL string; lower-case hyphenated UUID. A value derived from a parsed one (replace(),astimezone(), arithmetic) drops the wire text and takes that form.Scope. The rule covers the generated AdCP schema types (
adcp.types.domains).adcp.types.registry, the OpenAPI-generated registry client, keeps pydantic'sAnyUrl/AwareDatetime; no registry object is digest-covered, and the docs say so.Every
formatfield, not only digest-covered subtrees. Recommended and implemented. A shared class (AttestationReference,Provenance,Targeting) sits inside digested and undigested objects alike, so a per-subtree rule would have to split classes; and the invariant worth having is that the SDK never rewrites a wire value it did not change. The blast radius is one behaviour: a parsed URL/datetime/UUID/email dumps as received instead of normalized. No SDK code depended on the normalized spelling (the suite and the full test run show none).2. An explicit "dump as received" path.
AdCPBaseModel.model_dump_as_received()=model_dump(mode="json", exclude_unset=True, exclude_none=False, by_alias=True): leaves off every field the document did not set (where a generated default would otherwise appear), keeps an admitted explicitnull, writes wire names. This is the verification path for a digest until #1613 changes the default dump. For a model built in Python it emits exactly the fields the caller set.Close-out for #1610. With this PR,
content_digest(model.model_dump(mode="json")) == model.content_digestholds forAudienceEvidenceand for every digest-covered schema in the pin except two:terms_digestoncore/canonical-proposal.jsonandmedia-buy/commercial-terms.jsonstill does not round-trip under the default dump, because the generator materializestargeting_overlay.age_restriction.verification_requiredand five other defaults insidecommercial_terms; onlymodel_dump_as_received()recovers those bytes until #1613 lands. The conformance suite names both schemas explicitly.Why the default dump was not changed for defaults in 9.x. Leaving unset defaults off every
model_dump()is wire-equivalent for a schema-applying reader but visible to any code that indexes a dumped dict (payload["sandbox"]), including a counterparty that does not apply schema defaults. That is a 10.0 decision; the evidence and two designs are in #1613. The suite declares the two digest-covered schemas where the default dump still materializes a default (canonical-proposal,commercial-terms) as a shrink-only set, so the gap is visible and a third cannot join it.3.
AudienceEvidenceexported fromadcp.types, withAudienceEvidencePin,AudienceEvidenceSelection,CanonicalAudienceEvidence,CanonicalAudienceEvidenceSelection. The bare name resolves tocore.audience_evidence(the capabilities-response object of the same name is only reachable by its domain path, as before); public API snapshot regenerated.Tests
tests/test_wire_types.py: the issue's repro (content_digest(model.model_dump(mode="json")) == model.content_digest, bothmodel_validateandmodel_validate_json); every RFC 3339 and URL spelling round-trips; attribute API, equality, hashing, copies, constructed-value forms, validation unchanged, JSON Schema unchanged;model_dump_as_receivedon aCanonicalProposalwhoseterms_digestverifies only on that path; an admittednulland a wire alias.tests/conformance/test_wire_round_trip.py(795 cases, ~90 s): every bound schema has a document; every bound object round-trips byte-for-byte as received, in every form; the default dump never rewrites a bound object; every other schema's differences are inACCEPTED(with reason and issue) — a rewritten URL/datetime/UUID/email/value/shape fails anywhere; every accepted class still occurs (shrink-only); the as-received residue is dropped-unknown-fields only; refusing schemas are declared (shrink-only).tests/fixtures/round_trip/corpus.json: hand-written, validator-confirmed documents for the seven digest-covered schemas the instantiator cannot derive, pluscontext/extwith nested nulls.Existing tests touched by the behaviour change (each asserted the old normalization):
test_type_aliasesandtest_governance_authorizationpinnedstr(agent_url) == "https://agent.example.com/"for a model built from"https://agent.example.com"; they now expect the caller's bytes.test_code_generationpins the committed annotation spelling (WireDatetime | None).test_composability_invariantreviews the newAudienceEvidencepublic/capabilities name split.test_reporting_adjustment_evidenceasserted that a typed dump differs from the raw canonical JSON; it now shows that a typed view parsed from a differently spelled document hashes the same raw bytes. One SDK producer changed:build_reporting_adjustment_receiptwroteobserved_atwithisoformat()and relied on the model to rewrite+00:00asZ; it now writes theZform itself (rfc3339), so receipts keep their shape.canonical_creative._has_boundary_serialization_metadatatreats the wire types as plain (their python-mode dump is the stored value), so the direct boundary scan still applies.Performance (median of 7–9 runs, same machine;
mainmeasured in a worktree). A 2,000-productGetProductsResponsewith 24k URLs and 18k datetimes:model_validate_jsonmodel_dump_jsonmodel_dump(mode="json")model_dump()A scalar-only model (2,000 rows × 12 URLs + 9 datetimes, nothing else), which isolates the per-value cost:
model_validate_jsonmodel_dump_jsonmodel_dump(mode="json")model_dump()What changed: the datetime and UUID serializers run in JSON mode only (a python-mode dump returns the value with no Python call, as pydantic does for a
datetime); the datetime is built from its packed pickle state in one C call instead of nine arguments through an overriding__new__(3× cheaper); the URL validator is one Python layer around the core URL parser instead ofAnyUrl's wrap plus another; and the strict URI check takes a fast regex subset of RFC 3986 for the common case, falling back to the full validator only for userinfo, IP literals, non-ASCII or escapes. Validation of a scalar-only model stays ~2.6× main's 42 ms (≈1.6 µs per value: the strict check plus one Python call), which is the floor for a Python-level validator; on the real response it is within run-to-run noise. The 1.4–1.7×model_dump_jsonslowdown the review measured does not reproduce here on either corpus; if its corpus differs from these, I will rerun it.make lint,make typecheck-all,make validate-generatedpass.make test: 17,135 passed, 2,684 skipped; the 12 that failed on the first full run are the updates above plus one A2A integration test that passed on rerun, and all pass now. Pre-commit could not build its python3.11 environment in this sandbox, so the commit skipped hooks; black, ruff, mypy and bandit were run directly.Issues filed
extra='allow'on bound models; arm generation.serialize_by_alias=True; as-received path for nulls.TypeErrorfrommin_lengthon aLossesmodel while validatingMcpWebhookPayload/AsyncResponseData.🤖 Generated with Claude Code