Uh oh!
There was an error while loading. Please reload this page.
Refuse a bulk status write that would re-stamp an already-done task - #80
Conversation
A predicate (`multi: true`) update carries ONE payload for all N matched rows -- `driver.updateMany` takes a single SET clause -- so the `completed_at` that `task.hook.ts` stamps for a row genuinely transitioning into `done` was written to every row in the batch. A task completed days ago, swept into the same `status: 'done'` write, silently had its completion instant moved to now. ADR-0058 Addendum II D3 governs this: every per-row `beforeUpdate` context carries that one payload, so "a rewrite takes effect on the WHOLE batch, whichever row's dispatch made it", and a rewrite conditioned on the row is outside the contract. D3 names the sanctioned route -- per-row `previous` is supplied "so a guard can REFUSE the write, not so a rewrite can be aimed at one row". The hook now throws `DULY_TASK_BULK_ALREADY_DONE` (409), naming the row to remove. The guard is decided from the ROW alone -- its own pre-image plus the payload -- never from what an earlier dispatch left in the shared payload, so it holds in either dispatch order. It turns on `status` being in the payload, which is the only shape that computes a stamp, so an administrative bulk backfill and the seed's `mode: 'update'` backdating pass are untouched. Only the stamping direction is guarded: a batch moving rows out of `done` writes `completed_at = null`, which is correct for every row in such a batch and so is genuinely row-invariant. The `visible` predicate on `bulkActionDefs` is kept as the outer layer -- it stops the console assembling a batch the server would refuse -- but it is no longer the only thing between an import, a backfill, the dispatcher or an MCP caller and moved completion history. Its docblock, which claimed the predicate was the sole defence, is corrected. `test/task-actions.test.ts` previously PINNED the hazard (`.not.toBe(original)`); that assertion is replaced by the refusal, with the predicate half kept as its own case. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SqkTcrxUFci7nqXdbBSe2p
os-warren
commented
Sep 1, 2026
Reviewed, and I measured the one thing this design turns on — mergingRefusing a write on the product's central interaction is a bigger commitment than the card asked for, so the question I had to settle was not whether the guard is correct (the ADR-0058 Addendum II D3 reading is right, and the rejection of the issue's own first option on measurement is the right call) but whether a manager can hit this 409 by doing an ordinary thing. If selecting a few rows that happen to include a finished one produced a hard refusal of the whole batch, this would trade a subtle data bug for a loud usability one. It cannot. Driven in Chromium against the seeded data, selecting a mix of Two things there, both load-bearing:
Selecting only So the guard is genuine defence-in-depth against a programmatic or API caller, unreachable from the Console, and the Gates, re-run by me on The details I want to record because they are the reusable part:
#78 is the right call as a separate card: the same defect class on Generated by Claude Code |
Uh oh!
There was an error while loading. Please reload this page.
Fixes#39
Moves the defence from a client-side view predicate to the write itself.
The defect, reproduced
A predicate (
multi: true) update carries one payload for all N matched rows —driver.updateManytakes a single SET clause — so thecompleted_atthatsrc/hooks/task.hook.tsstamps for a row genuinely transitioning intodonewas written to every row in the batch. A task completed days ago, swept into the samestatus: 'done'write, silently had its completion instant moved to now. Nothing errored.Confirmed on a booted engine before writing the fix. The three refusal tests fail against the unmodified hook with:
Why refusing is the fix, not skipping the stamp
ADR-0058 Addendum II is the authority, and it settles the shape rather than leaving it to taste. D1: a
before*event dispatches once per matched row. D3: every per-row context carries that one payload, so "a rewrite takes effect on the WHOLE batch, whichever row's dispatch made it", and therefore "a rewrite CONDITIONED on the row (ctx.previous,ctx.input.id) is outside this contract". D3 then names the route in as many words:completed_atis exactly such a rewrite — it is read off this row's pre-image. So the hook throws.The issue's other candidate — skip the stamp on the bulk path and let
completed_at_required_when_donerefuse — was measured against and rejected: it takes bulk complete away entirely. With no stamp, a homogeneous batch of 20 open rows writesstatus: 'done'with no timestamp and the validation rule refuses the whole thing. "A week's worth of ticks in one gesture" is the feature #4 shipped; the guard has to refuse the mixed batch without costing the homogeneous one.How the dispatch path is detected
ctx.dispatchis the engine's own marker, a declared field onHookContextSchema(mode: 'record' | 'per-row'), not an inference. Measured on this repo's declarative hook rather than assumed — the issue suggested readinginput.options, which does work, but arrives as a non-enumerable property (absent fromObject.keys(input)), whereasdispatch.modeis typed and contract-first:That third line is D3 happening in the open: dispatch 1 receives a payload already carrying the
last_update_atdispatch 0 wrote.Three boundaries the guard deliberately holds
statusbeing in the payload. A predicate write that does not writestatuscomputes no stamp, so there is nothing to leak. An administrative bulk backfill over done rows, and the seed'smode: 'update'backdating pass, are untouched — the latter matters becausetest/seed-history.test.tsis load-bearing and stays green.donewritescompleted_at = null, and null is correct for every row being moved out of done, including one never completed. That rewrite is genuinely row-invariant, so it is allowed.A batch in which every row is already done is refused too, though nothing would leak. The hook cannot see the batch —
dispatch.indexis a position, not a total — and a rule stated on the row is one a caller can predict and a test can pin.The view predicate is kept
visibleonbulkActionDefsstill excludes done rows, so the console cannot assemble a batch the server now refuses and a user gets an unavailable action rather than an error they did not cause. Its docblock claimed the predicate was the sole defence and cited the hazard as live; that is now false, so it is corrected rather than left to mislead the next reader.test/task-actions.test.tspreviously pinned the hazard (.not.toBe(original)). That assertion is replaced by the refusal, with the predicate half kept as its own case — the test pinned the branch this PR removes, so rewriting it was the honest triage, not a convenience.Verification
All four gates, run under this container's shared verify lock on the final commit
dfd0a48, each read from the gate's own verdict line:Blast radius before the test rewrite was exactly one red test repo-wide (
Tests 1 failed | 542 passed) — the one pinning the bug.The handler is lowered into a sandboxed metadata
bodyat build time, which silently degrades if it references module scope, so the built artifact was checked rather than trusted —dist/objectstack.json→hooks[0].bodycarries the guard intact:Refusals assert the ADR-0112 envelope (
code+status), never a baretoThrow(). Measured incidentally and worth recording: a throw from a declarative hook underonError: 'abort'reaches the caller withcodeandstatusintact.No changeset
This repo has no changesets tooling — no
.changeset/now or anywhere ingit log --all, no@changesets/*dependency, no script, no mention inAGENTS.md. Creating the directory would mint a mechanism nothing reads and the next agent would maintain. The four gates are the whole contract here.Out of scope, filed separately
Issue #78 records the same defect class on the sibling column, and remains open — nothing here addresses it.
last_update_atis stamped only whenstatus/note/skip_reasonactually changed against that row's pre-image, which is equally row-conditional, so in a bulk write where some rows change and others do not, the unchanged rows get their clock advanced anyway. Measured: a row whose note already equalled the payload's moved…986Z→…996Z. That quiets the "Not moving" signal, which is the harm the hook's own docblock exists to prevent. Left out deliberately: the safe rule is a genuine product call (refusing all-unchanged batches over-refuses; watching the payload accumulate is order-dependent; skipping the stamp makes bulk completion look like stagnation), not a mechanical change.Generated by Claude Code