Found working the #3909 G3/G4 checklist on main @ a1a855a28 (the rc.3 train tip), driving the showcase.
updateMany runs record-change hooks for an id that does not exist. With no stored record to merge, the hook's condition evaluates against a payload-only record, the field it reads is absent, and #4775's new abort fires — so the row fails with INTERNAL_ERROR from a hook diagnostic instead of RECORD_NOT_FOUND.
Repro
The single-record path is correct (this is the #4435 fix, verified separately):
PATCH /api/v1/data/showcase_task/definitely_missing {"progress":1}
→ 404 {"code":"RECORD_NOT_FOUND","error":"Record definitely_missing not found in showcase_task"}
The batch path is not:
POST /api/v1/data/showcase_task/updateMany
{"records":[{"id":"definitely_missing","data":{"progress":1}}]}
→ 200, row 0 failed:
{"code":"INTERNAL_ERROR",
"message":"Hook 'showcase_audit_task_completion' could not evaluate its condition
(runtime: No such key: done) — operation aborted. The condition reads 'done',
which this object does not declare — fix the hook's condition, or declare the field."}
showcase_taskdoes declare done; the hook is correct. The message is wrong because the record it evaluated against was the patch payload alone.
With atomic: true the same shape poisons the whole batch — row 0 fails this way and every later row comes back NOT_ATTEMPTED:
{"succeeded":0,"failed":2,"codes":["INTERNAL_ERROR","NOT_ATTEMPTED"]}
Real ids behave correctly on the same route ({"succeeded":2,"failed":0}), so this is specific to the missing-record case.
Why it matters
Three separate contracts this train shipped disagree here:
The practical harm is the misdirection: an operator who batch-updates with one stale id is told their hook is broken and pointed at the object's field list. Under atomic they also lose every other row in the batch to NOT_ATTEMPTED, so a single stale id in a large batch reads as a platform failure.
Expected
updateMany resolves each row's stored record before running hooks, and a row whose id resolves to nothing fails with RECORD_NOT_FOUND — matching the single-record path — rather than entering the hook pipeline with a payload-only record. If a hook must run for a missing record, the merged record should be recognisable as incomplete so #4775 does not attribute the gap to the hook's author.
Found during the #3909 rc.3 verification (G3/G4).
Found working the #3909 G3/G4 checklist on
main@a1a855a28(the rc.3 train tip), driving the showcase.updateManyruns record-change hooks for an id that does not exist. With no stored record to merge, the hook's condition evaluates against a payload-only record, the field it reads is absent, and #4775's new abort fires — so the row fails withINTERNAL_ERRORfrom a hook diagnostic instead ofRECORD_NOT_FOUND.Repro
The single-record path is correct (this is the #4435 fix, verified separately):
The batch path is not:
showcase_taskdoes declaredone; the hook is correct. The message is wrong because the record it evaluated against was the patch payload alone.With
atomic: truethe same shape poisons the whole batch — row 0 fails this way and every later row comes backNOT_ATTEMPTED:Real ids behave correctly on the same route (
{"succeeded":2,"failed":0}), so this is specific to the missing-record case.Why it matters
Three separate contracts this train shipped disagree here:
RECORD_NOT_FOUND. Honoured on PATCH, not onupdateMany.condition求不出值时:全局 fail loud —— 抛错并中断该次操作(方案 B 已拍板;Blocked-by #4770) #4775 — an unevaluable condition aborts. Correct behaviour firing on a case that should never have reached it, which turns a routine caller mistake into a diagnostic accusing a correct hook of naming an undeclared field.The practical harm is the misdirection: an operator who batch-updates with one stale id is told their hook is broken and pointed at the object's field list. Under
atomicthey also lose every other row in the batch toNOT_ATTEMPTED, so a single stale id in a large batch reads as a platform failure.Expected
updateManyresolves each row's stored record before running hooks, and a row whose id resolves to nothing fails withRECORD_NOT_FOUND— matching the single-record path — rather than entering the hook pipeline with a payload-only record. If a hook must run for a missing record, the merged record should be recognisable as incomplete so #4775 does not attribute the gap to the hook's author.Found during the #3909 rc.3 verification (G3/G4).