Blog · July 27, 2026
A task_update call could flip a task to done and supply the passing acceptance_criteria in the same breath — setting its own bar and clearing it in one motion.
Ledgenter gates the transition into done: a task carrying acceptance_criteria can't complete while any criterion is still unmet. That gate has existed since 0020, tightened in 0070 (a workspace-wide evidence floor) and 0080 (gate on the latest review round, not any review ever left). All of it assumes one thing that was never actually enforced: that the criteria being checked were set by an earlier, independent act — not invented on the spot by the same call trying to pass them.
The call that could grade its own homework
task_update takes a single JSON patch. Nothing stopped that patch from containing both status: "done" and a fresh acceptance_criteria array where every item already reads met: true:
{ "status": "done", "acceptance_criteria": [{ "text": "tests pass", "met": true }] }
Read the gate's own logic and this call was legal. The function computes an effective post-patch criteria set — p_patch's array if the patch supplies one, otherwise the row's stored one — and checks that for unmet items before allowing done. A patch that supplies its own already-met criteria in the same call is the effective set. There was no earlier, independent moment the bar had to survive; the bar and the pass were the same write.
For a human clicking a checkbox this is a non-issue — a person marking their own work "done: criteria met" is just... doing their job. The property Ledgenter actually needs is narrower and sharper: a task's completion bar has to have been set before the attempt that clears it, so done means something was checked against a standard that existed independently of the actor doing the checking — not "I decided what counted as passing and then declared I passed it," which is exactly the shape a self-reporting agent loop can produce without any bad intent, just by doing the criteria-setting and the completing in one convenient call.
The fix: one gate, one narrow condition
Migration 0096 adds a single guard, scoped to exactly the transition that matters — into done, only when the same patch also touches acceptance_criteria:
if v_next = 'done' and v_cur is distinct from 'done' then
-- R1 mutation-guard (0096): the verifier bar must be set BEFORE the completion
-- attempt, never relaxed in the same call. A patch that supplies its own
-- acceptance_criteria while transitioning to done would pass its OWN gate
-- (self-attested completion). Reject it; flip criteria met in a prior update, then
-- complete with a criteria-free patch so this gate evaluates the STORED criteria.
if (p_patch ? 'acceptance_criteria') then
raise exception 'cannot change acceptance_criteria in the same update that completes the task — flip criteria met in a prior call, then complete'
using errcode = '23514';
end if;
...
Two calls, not one: flip the criterion to met: true in a call that leaves status untouched, then complete with a criteria-free patch. The second call's gate now evaluates the stored row — criteria that survived at least one independent write — never anything the completing call invented for itself. A task with no acceptance_criteria at all is untouched; {status: "done"} alone never carries the key the guard checks for, so the ordinary case doesn't even see this code path.
The scope was deliberately kept narrow. The same same-call-relaxation shape exists elsewhere in task_update — requires_evidence and reviewer_actor_id can also, in principle, be loosened in a call that then relies on the loosened value. Both were left alone here: evidence still requires a real code_ref or attachment to exist regardless of the flag, and clearing a reviewer is a documented, intentional escape hatch, not an accident of timing. A gate that closes exactly the loophole it names, and nothing it doesn't, is easier to reason about later than one that quietly widened its own mandate while it was in there.
Three tests, one behavior each
The coverage pins exactly the three shapes that matter, not just the happy path:
-- 0096 R1(a): ->done carrying acceptance_criteria in the same call raises 23514
select throws_ok(
$$ select public.task_update(:'r1raise', '{"status":"done","acceptance_criteria":[{"text":"crit-a","met":true}]}'::jsonb) $$,
'23514', 'cannot change acceptance_criteria in the same update that completes the task — flip criteria met in a prior call, then complete',
'0096 R1(a): self-attested completion blocked');
- R1(a) — the self-attest call is rejected, and the task is confirmed still
todoafterward, not silently half-applied. - R1(b) — the legitimate two-step flow: flip
met: truein a status-untouched call, then complete with a criteria-free patch. Confirms the gate isn't just blocking the bad shape, it's still passing the correct one. - R1(c) — a task with
acceptance_criteria: []completes on a bare{status: "done"}exactly as before. The guard checks for the key, not for criteria existing, so tasks that never opted into the gate feel nothing.
All three run against task_update's existing fixture set — no schema change, no new column, no return-shape change. A body-only edit to an existing RPC, so it carried no public-contract drift and shipped straight to master on the same hourly deploy lane as everything else.
The general shape
The bug class here isn't specific to acceptance criteria — it's any gate that reads both the bar and the value being checked against it from the same input. If a function computes "effective state" as patch ?? stored and then validates the effective state, anything the patch is allowed to set becomes something the caller can set and immediately satisfy in one call. The fix isn't to distrust the caller more broadly; it's to require that the specific field the gate depends on came from a write that already happened — a bar that has to have existed before the attempt to clear it, not one instantiated by the attempt itself.
Start at ledgenter.com.