fix(api): apply only the fields a ticket PATCH actually sent - #52
Conversation
FastAPI parses `{"inputs": {"uri_processes": [...]}}` into a complete
TicketInputs, filling every omitted field with its model default, and the
store applies sections with a shallow dict update. One partial write
therefore erased everything the caller did not mention.
Observed on a running deployment, 2026-09-03. Twelve supervisor plans sat in
waiting_input for up to 152 hours with an automatic recovery that could never
see them: the recovery keys on inputs.api_body, the producer wrote it, and a
later unrelated PATCH reset it to None. The same shape erased
execution.last_error and left scheduler occurrences with no started_at, so a
watchdog failed handlers that had in fact run cleanly.
Pydantic records which fields were present at each nesting level, so merge
the sent keys of executor, execution, inputs and outputs onto what is stored.
An explicitly sent null still clears its field; only omission is now inert.
Six tests pin the behaviour, including the two exact production shapes. Both
fail without the merge; the four that assert unchanged semantics — overwrite,
explicit null, untouched sections, a patch naming no section — pass either
way and stand as regression guards.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015QJeEkvVSGaDJyaxu4aoRL
There was a problem hiding this comment.
Deterministic Validator approval for exact head b0f12873be5059f4ab1981fb6d102df4c94d9fb3.
Ticket: ticket-058
Correlation ID: local-semcod-planfile-pr-52-ticket-058
Model: zai/glm-5.3
Reviewed diff chunks: 1
Advisory LLM verdict: APPROVE
Advisory summary: Reviewed all 1 diff chunk(s). PATCH /tickets/{id} previously wrote parsed sub-models through wholesale, erasing unmentioned fields within executor/execution/inputs/outputs. The fix merges only fields present at each nesting level via model_fields_set and exclude_unset, preserving explicit nulls as clears. Six new tests cover the production regression shapes and unchanged semantics; all CI checks pass.
Advisory findings: none
The LLM output above is advisory and was not used as the approval trust root.
Actual PR impact radar
Exact range: a35a79ef901454c01409ffe3ab6144522163a131...b0f12873be5059f4ab1981fb6d102df4c94d9fb3
Change digest: 1d3604f507e002e3a39967584a8c0afa44636bc9390eee5f03c0379b3bd8eeda
Score: 72/100 (L), estimated 84 min, split recommended: true
Affected services/components: repository-wide/unclassified
Machine-readable radar JSONL and SVG
{"actual_change":{"additions":139,"base_sha":"a35a79ef901454c01409ffe3ab6144522163a131","binary_files":0,"categories":{"code":1,"tests":1},"change_digest":"1d3604f507e002e3a39967584a8c0afa44636bc9390eee5f03c0379b3bd8eeda","comparison":"a35a79ef901454c01409ffe3ab6144522163a131...b0f12873be5059f4ab1981fb6d102df4c94d9fb3","deletions":1,"file_count":2,"files":["planfile/api/server.py","tests/test_partial_ticket_update.py"],"head_sha":"b0f12873be5059f4ab1981fb6d102df4c94d9fb3","service_count":0,"services":[]},"assessment_mode":"observed-pr","axes":{"coupling":5,"delivery":4,"scope":3,"uncertainty":3,"validation":3},"complexity":"L","confidence":0.9,"diagnostics":["RADAR-ACCEPTANCE-MISSING","RADAR-BUDGET-EXCEEDED"],"estimate":{"budget_minutes":30,"minutes":84,"within_budget":false},"impact":{"components":["12","3.10","claude.ai","claude.com","omission","planfile","tests"],"files":["12/12","3.10/3.13","claude.ai/code/session_015QJeEkvVSGaDJyaxu4aoRL","claude.com/claude-code","omission/overwrite/explicit-null","planfile/api/server.py","tests/test_partial_ticket_update.py"],"public_interfaces":["planfile/api/server.py"],"runtime_dependencies":0},"schema":"subactor.ticket-radar/v1","score":72,"split":{"parts":[{"estimated_minutes":20,"name":"Define contract and acceptance boundary","scope":["planfile/api/server.py"]},{"estimated_minutes":10,"name":"Implement 12","scope":["12"]},{"estimated_minutes":10,"name":"Implement 3.10","scope":["3.10"]},{"estimated_minutes":10,"name":"Implement claude.ai","scope":["claude.ai"]},{"estimated_minutes":10,"name":"Implement claude.com","scope":["claude.com"]},{"estimated_minutes":10,"name":"Implement omission","scope":["omission"]},{"estimated_minutes":15,"name":"Validate and project to trackers","scope":["tests","planfile","github/gitlab/jira projections"]}],"reason":"estimated_minutes_exceed_budget","recommended":true},"standards":[{"id":"wellmanifest/dsl","revision":"6c60fc4e0dd1f1bb74f46a7745e28019908d1203","version":"0.1.0-dev"},{"id":"wellmanifest/ticket-lifecycle","revision":"5bf581907a87b46a13a73e6c033d3abe4d9a306f","version":"0.1.0-dev"},{"id":"wellmanifest/git-lifecycle","revision":"7d77d4b7af57e69bc75c3a0290b3a4805c5c4438","version":"0.2.0-dev"},{"id":"wellmanifest/logs","revision":"48c284ef7a069055c0bcb6b900147ce5e65f8b43","version":"0.3.0"}],"ticket_ref":"ticket-058"}<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128" viewBox="0 0 128 128" role="img"><title>ticket-058: fix(api): apply only the fields a ticket PATCH actually sent</title><rect width="128" height="128" rx="12" fill="#f8fafc"/><g stroke-width="1"><polygon points="64,55 72,61 69,71 59,71 56,61" fill="none" stroke="#d7dde5"/><polygon points="64,47 80,59 74,78 54,78 48,59" fill="none" stroke="#d7dde5"/><polygon points="64,38 89,56 79,85 49,85 39,56" fill="none" stroke="#d7dde5"/><polygon points="64,30 97,53 84,92 44,92 31,53" fill="none" stroke="#d7dde5"/><polygon points="64,21 105,51 89,99 39,99 23,51" fill="none" stroke="#d7dde5"/><line x1="64" y1="64" x2="64" y2="21" stroke="#aab4c0"/><line x1="64" y1="64" x2="105" y2="51" stroke="#aab4c0"/><line x1="64" y1="64" x2="89" y2="99" stroke="#aab4c0"/><line x1="64" y1="64" x2="39" y2="99" stroke="#aab4c0"/><line x1="64" y1="64" x2="23" y2="51" stroke="#aab4c0"/></g><polygon points="64,38 105,51 79,85 49,85 31,53" fill="#fb923c" fill-opacity="0.45" stroke="#c2410c" stroke-width="2"/><circle cx="64" cy="64" r="3" fill="#c2410c"/><g font-family="sans-serif" font-size="7" fill="#334155"><text x="64" y="11" text-anchor="middle">SCO</text><text x="114" y="48" text-anchor="middle">COU</text><text x="95" y="107" text-anchor="middle">UNC</text><text x="33" y="107" text-anchor="middle">VAL</text><text x="14" y="48" text-anchor="middle">DEL</text></g><text x="64" y="124" text-anchor="middle" font-family="sans-serif" font-size="8" fill="#0f172a">L · 84m</text></svg>DECISION D-058-3220
TICKET ticket-058
HEAD_SHA b0f12873be5059f4ab1981fb6d102df4c94d9fb3
CORRELATION_ID local-semcod-planfile-pr-52-ticket-058
ACTOR agent:ifuri-validator-agent[bot]
APPLIED_RULE P-CORE-015
INPUT author_login = "tom-sapletta-com"
INPUT observed_checks = ["notify=PASS","test (3.10)=PASS","test (3.13)=PASS","ci-loop=PASS"]
INPUT required_checks = ["ci-loop","notify"]
INPUT required_checks_source = "protected registry (env/request)"
INPUT reviewer_login = "ifuri-validator-agent[bot]"
INPUT superseded_checks = []
INPUT ticket_radar_receipt = {"schema":"subactor.ticket-radar/v1","base_sha":"a35a79ef901454c01409ffe3ab6144522163a131","head_sha":"b0f12873be5059f4ab1981fb6d102df4c94d9fb3","change_digest":"1d3604f507e002e3a39967584a8c0afa44636bc9390eee5f03c0379b3bd8eeda","score":72,"complexity":"L","estimated_minutes":84,"split_recommended":true,"services":[],"authority":"ADVISORY","promotion":"FORBIDDEN"}
VERDICT APPROVE AUTHORITY DETERMINISTIC
REJECTED REQUEST_CHANGES BECAUSE NO_UNSAFE_CHANGE_REASON_FOUND
ADVISORY llm_verdict = "APPROVE" MODEL "zai/glm-5.3"
ASSERT VERDICT_AUTHORITY != "ADVISORY"
A
PATCH /tickets/{id}erased every field of a section the caller did not mention.FastAPI parses
{"inputs": {"uri_processes": [...]}}into a completeTicketInputs, filling every omitted field with its model default, and the store applies sections with a shallowdict.update. So one partial write reset the rest of the section to defaults.Observed on a running deployment, 2026-09-03
Twelve supervisor plans sat in
waiting_inputfor up to 152 hours with an automatic recovery that could never see them:inputs.api_bodycarrying the supervisor envelopeNonesupervisor_strategy_recovery_examined: 0The same shape showed up twice more in the same investigation:
inputs.api_bodyexecution.last_error,finished_atexecution.started_at(never written by the heartbeat's section)One root cause, three symptoms.
Change
Pydantic records which fields were present at each nesting level, so the handler merges the sent keys of
executor,execution,inputsandoutputsonto what is stored, instead of writing the parsed object through.An explicitly sent
nullstill clears its field — only omission is now inert.Tests
Six cases. The two reproducing the production shapes fail without the merge. The four asserting unchanged semantics — a sent field still overwrites, an explicit null still clears, untouched sections are left alone, a patch naming no section changes nothing — pass either way and stand as regression guards.
Full suite: 420 passed, 6 skipped, 0 failed.
🤖 Generated with Claude Code
https://claude.ai/code/session_015QJeEkvVSGaDJyaxu4aoRL
Publication continuation: integrated current main without rewriting original history. Locked Python 3.13 suite: 446 passed, 6 skipped, including all omission/overwrite/explicit-null regressions. Docker Compose configuration parses successfully. Current Python 3.10/3.13 and existing hosted gates must pass before independent exact-head approval.
Ticket: ticket-058. Closes #58.
Correlation ID: planfile-pr52-ticket058-publication-20260908
Final integration: merged the approved PR #51 result from main without conflicts. The combined locked suite passes: 469 passed, 6 skipped on Python 3.13. CI is rerun on this final head before independent approval.