Found while building #4 (bulk complete / bulk skip). Measured against a booted engine, not reasoned about.
What happens
A predicate (multi: true) update carries one payload for all N matched rows — driver.updateMany takes a single SET clause, and ADR-0058 Addendum II D3 says so explicitly: a rewrite made during any row's beforeUpdate dispatch "takes effect on the WHOLE batch, whichever row's dispatch made it".
src/hooks/task.hook.ts stamps on the transition:
if(!wasDone&&isDone)input.completed_at=now;
For a batch containing one open row and one already-done row, both being written status: 'done', the open row's dispatch sets input.completed_at = now — and that lands on the done row too. Its original completion instant is overwritten.
Reproduced in test/task-actions.test.ts → bulk > the visible predicate is what keeps an already-done row out of the batch:
MIXED_RESTAMPED true before=2026-09-01T04:37:26.560Z after=2026-09-01T04:37:26.571Z
The single-record path is correct and stays correct — task-hook.test.ts already pins "is not re-stamped when an already-done task is saved again". This is specific to the shared-payload multi write.
Why it is not reachable today
The bulkActionDefs entries #4 landed carry visible: Precord.status == "open" || record.status == "in_progress"``, and a bulk predicate is evaluated once per selected record with only the passing rows included in the run. So the UI cannot assemble a batch containing a done row. test/task-actions.test.ts pins both halves — the hazard, and the predicate that excludes it.
Why it is still worth closing
The guard is client-side, and the hook's own docblock is explicit that a client-side hide is not the authority. Anything that writes duly_task in bulk without going through that view — a future import, a backfill job, the dispatcher, an MCP caller, updateMany over a filter — reassembles the batch and silently moves completion history. Nothing errors; the numbers just quietly change.
Shape of a fix (for triage, not prescriptive)
The hook can tell it is on the shared-payload path: a predicate write's per-row context arrives with input.id bound and the caller's multi/where still visible in input.options during the before* phase. Options roughly in order of appetite:
- Skip the
completed_at stamp when the batch is not row-invariant, and let completed_at_required_when_done refuse the write loudly — consistent with how the hook already treats the unscoped-multi case ("stamping nothing is fail-safe in both directions"). - Or refuse a multi write that mixes transitioning and non-transitioning rows outright.
Both change src/hooks/task.hook.ts, which #4 deliberately did not touch (adjudicated: the actions only flip status).
Filed unassigned.
Found while building #4 (bulk complete / bulk skip). Measured against a booted engine, not reasoned about.
What happens
A predicate (
multi: true) update carries one payload for all N matched rows —driver.updateManytakes a single SET clause, and ADR-0058 Addendum II D3 says so explicitly: a rewrite made during any row'sbeforeUpdatedispatch "takes effect on the WHOLE batch, whichever row's dispatch made it".src/hooks/task.hook.tsstamps on the transition:For a batch containing one
openrow and one already-donerow, both being writtenstatus: 'done', the open row's dispatch setsinput.completed_at = now— and that lands on the done row too. Its original completion instant is overwritten.Reproduced in
test/task-actions.test.ts→bulk > the visible predicate is what keeps an already-done row out of the batch:The single-record path is correct and stays correct —
task-hook.test.tsalready pins "is not re-stamped when an already-done task is saved again". This is specific to the shared-payload multi write.Why it is not reachable today
The
bulkActionDefsentries #4 landed carryvisible: Precord.status == "open" || record.status == "in_progress"``, and a bulk predicate is evaluated once per selected record with only the passing rows included in the run. So the UI cannot assemble a batch containing a done row.test/task-actions.test.tspins both halves — the hazard, and the predicate that excludes it.Why it is still worth closing
The guard is client-side, and the hook's own docblock is explicit that a client-side hide is not the authority. Anything that writes
duly_taskin bulk without going through that view — a future import, a backfill job, the dispatcher, an MCP caller,updateManyover a filter — reassembles the batch and silently moves completion history. Nothing errors; the numbers just quietly change.Shape of a fix (for triage, not prescriptive)
The hook can tell it is on the shared-payload path: a predicate write's per-row context arrives with
input.idbound and the caller'smulti/wherestill visible ininput.optionsduring thebefore*phase. Options roughly in order of appetite:completed_atstamp when the batch is not row-invariant, and letcompleted_at_required_when_donerefuse the write loudly — consistent with how the hook already treats the unscoped-multi case ("stamping nothing is fail-safe in both directions").Both change
src/hooks/task.hook.ts, which #4 deliberately did not touch (adjudicated: the actions only flipstatus).Filed unassigned.