Summary
fire_at is agent-supplied (MCP set_reminder, z.string().optional()). A string containing a Z in the middle passes Pydantic validation and then raises out of the parser, producing HTTP 500 where 400 belongs.
Found by the property test_P7_validator_acceptance_implies_the_parser_can_parse in #1771 slice b (PR #1826), shipped there as a strict=True xfail with no product fix (that PR's AC forbids product-code change).
Root cause — two normalizers that disagree
|
Code |
Behaviour |
| Validator |
src/backend/models.py:2134 — datetime.fromisoformat(v.replace("Z", "+00:00")) |
replaces every Z |
| Parser |
src/backend/utils/helpers.py:117-119 — if timestamp.endswith('Z'): timestamp = timestamp[:-1] + '+00:00' |
strips only a trailing Z |
So 2026-01-15T10:30Z:00 normalizes, under the validator, to a string fromisoformat accepts — but the parser leaves the mid-string Z in place and raises ValueError.
Verified reproduction
Measured directly (not inferred), on the interpreters that matter:
| Python |
Inputs reproducing |
3.13 (prod image, docker/backend/Dockerfile:1) |
4 / 4 |
| 3.12 |
4 / 4 |
| 3.14 |
2 / 4 — fromisoformat tightened |
The four inputs:
2026-01-15T10Z:30:00
2026-01-15T10:30Z:00
2026-01-15 10Z:30:00
2026-01-15 10:30Z:00
On 3.14 only the two 10:30Z:00 shapes still reproduce. The xfail's example set therefore narrows but stays non-empty if Trinity ever moves to 3.14 — the strict xfail keeps holding.
Why it reaches the client as a 500
reminder_service._resolve_fire_at:38-39 calls parse_iso_timestamp(data.fire_at) with no try/except, under a comment asserting the very invariant this falsifies ("fire_at already validated ISO-8601 by ReminderCreate").
routers/reminders.py:120-137 catches except HTTPException: only, so the bare ValueError propagates to FastAPI.
Amplifier — needs confirmation against a live backend
The same escape appears to skip idempotency_service.fail(idem), stranding the in_flight claim. Because the key is derived over the raw input under the 24h default TTL, an identical retry would then get 409 "still being processed" for up to 24 hours instead of a second 500. This is a code-read inference and has not been executed — verify before relying on it.
Suggested fix
Make the two normalizers agree. Either give parse_iso_timestamp the strict contract and have the validator use it, or (minimal) tighten the validator to strip only a trailing Z. Then delete the strict=True xfail in tests/unit/test_1771b_timestamp_helpers_properties.py:529 — while it is strict, a fix makes it XPASS and fail the suite, which is the intended alarm, not a broken test.
There is a second replace("Z", "+00:00") at src/backend/models.py:358 worth auditing for the same divergence.
Found via #1771 (PR #1826).
Summary
fire_atis agent-supplied (MCPset_reminder,z.string().optional()). A string containing aZin the middle passes Pydantic validation and then raises out of the parser, producing HTTP 500 where 400 belongs.Found by the property
test_P7_validator_acceptance_implies_the_parser_can_parsein #1771 slice b (PR #1826), shipped there as astrict=Truexfail with no product fix (that PR's AC forbids product-code change).Root cause — two normalizers that disagree
src/backend/models.py:2134—datetime.fromisoformat(v.replace("Z", "+00:00"))Zsrc/backend/utils/helpers.py:117-119—if timestamp.endswith('Z'): timestamp = timestamp[:-1] + '+00:00'ZSo
2026-01-15T10:30Z:00normalizes, under the validator, to a stringfromisoformataccepts — but the parser leaves the mid-stringZin place and raisesValueError.Verified reproduction
Measured directly (not inferred), on the interpreters that matter:
docker/backend/Dockerfile:1)fromisoformattightenedThe four inputs:
On 3.14 only the two
10:30Z:00shapes still reproduce. The xfail's example set therefore narrows but stays non-empty if Trinity ever moves to 3.14 — the strict xfail keeps holding.Why it reaches the client as a 500
reminder_service._resolve_fire_at:38-39callsparse_iso_timestamp(data.fire_at)with no try/except, under a comment asserting the very invariant this falsifies ("fire_at already validated ISO-8601 by ReminderCreate").routers/reminders.py:120-137catchesexcept HTTPException:only, so the bareValueErrorpropagates to FastAPI.Amplifier — needs confirmation against a live backend
The same escape appears to skip
idempotency_service.fail(idem), stranding thein_flightclaim. Because the key is derived over the raw input under the 24h default TTL, an identical retry would then get 409 "still being processed" for up to 24 hours instead of a second 500. This is a code-read inference and has not been executed — verify before relying on it.Suggested fix
Make the two normalizers agree. Either give
parse_iso_timestampthe strict contract and have the validator use it, or (minimal) tighten the validator to strip only a trailingZ. Then delete thestrict=Truexfail intests/unit/test_1771b_timestamp_helpers_properties.py:529— while it is strict, a fix makes it XPASS and fail the suite, which is the intended alarm, not a broken test.There is a second
replace("Z", "+00:00")atsrc/backend/models.py:358worth auditing for the same divergence.Found via #1771 (PR #1826).