Skip to content

plans: refuse an HTTP decline once the plan is no longer pending - #250

Closed
oranjeai wants to merge 1 commit into
ClickHouse:mainfrom
oranjeai:oranjeai/plan-decline-precondition
Closed

plans: refuse an HTTP decline once the plan is no longer pending#250
oranjeai wants to merge 1 commit into
ClickHouse:mainfrom
oranjeai:oranjeai/plan-decline-precondition

Conversation

@oranjeai

@oranjeai oranjeai commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Problem

PATCH /api/plans/{plan_id} writes a caller-supplied status with no precondition of any kind,
while all four sibling surfaces refuse a non-pending plan (tool plan_decline, plan_approve,
plan_update, and POST /api/plans/{id}/approve, which returns 409).

So a {"status": "declined"} PATCH against a plan under active implementation succeeds: the row
flips to declined while the implementation session keeps running, and the route's task_done side
effect moves the task file into done/ under an agent still writing to it. The store's update_plan
is an unconditional UPDATE ... WHERE id = ?, so nothing re-checks. No concurrency is involved: the
tool surface refuses the identical call sequentially. The route was written as a generic
field-updater rather than the lifecycle transition it performs, so it inherited none of its siblings'
preconditions. Reachable from the UI, whose Decline button renders off a plan loaded once, never
re-polled.

Fix

Add the precondition where the others enforce it: right after loading the row, before any write. The
raise is the approve route's, with one word changed; the condition adds the declined scope:

if req.status == "declined" and plan["status"] != "pending":
    raise HTTPException(status_code=409, detail=f"Plan is '{plan['status']}', only 'pending' plans can be declined")

It precedes the write and the task_done invocation, so a refused decline changes nothing. Only
declined is guarded, since it is the only PATCH-reachable transition with a task-closing side
effect; a test pins that scope. Intended behaviour change: this PATCH now returns 409 where it
returned {"updated": true}. That success was a lie, and the shape matches the approve route's
existing 409.

Web: the 409 was swallowed twice. The store logged it and set no actionError; and handleDecline
did not await, so it cleared the form and the typed reason unconditionally, which read as success.
Compounding both, the only actionError block sat inside the revise form, so it never
rendered on decline. updatePlan now
returns a boolean and sets actionError via the existing extractErrorMessage, handleDecline
awaits it and clears only on success, and the error block serves both forms.

Tests

tests/test_plan_decline_precondition.py (new, 15 cases): 409 for an implementing plan with the row
and the task asserted untouched, the pending happy path unchanged, every non-pending status
parametrized, a surface-parity assertion that tool and route agree per status, and a superseded
scope test.

Without the fix (clean export of main plus the new test file) 13 failed, 2 passed; with it
15 passed.

Validation detail

Both arms re-run against the final tree. Base arm from a clean git archive main | tar -x export
(not git checkout main -- nerve/, which leaves files absent from that revision in place), with the
new test copied in: 13 failed, 2 passed, 0 collection errors. The 2 passing in both arms are the
happy-path and scope-limit regression guards.

Full suite: base 20 failed, 2935 passed -> 7 failed, 2948 passed. Comparing sorted FAILED-name
sets, the fix introduces no new failure; the 7 residuals are pre-existing and identical in both
arms (test_memu_bridge x6, test_telegram_sessions x1), none plan-related.

Mutation matrix on the guard, each mutant asserting its edit applied exactly once and still parses,
with the unmutated control green at both ends: deleted (the original defect), after the write, after
task_done, applied to all statuses, 400 instead of 409, predicate inverted, and letting
approved through. All 7 killed.

ruff check clean; npx tsc -b clean; npx eslint 0 errors (its 1 react-hooks/exhaustive-deps
warning is pre-existing and identical on main). web/ has no test runner (package.json scripts
are dev/build/lint/preview), so the three web changes are verified by typecheck, lint and
reading, not by an automated kill.

Not addressed: the plan-status writers are still non-atomic read-then-write pairs, so a concurrent
approval can clobber a sibling transition. That needs a CAS on all four writers, not fixable
independently while approval's own write is unconditional. Tracked separately.

PATCH /api/plans/{plan_id} wrote a caller-supplied status with no
precondition of any kind, while all four sibling surfaces refuse a
non-pending plan (tool plan_decline/plan_approve/plan_update and
POST /api/plans/{id}/approve, which returns 409).

So a {"status": "declined"} PATCH against a plan under active
implementation succeeded: the row flipped to declined while the
implementation session kept running, and the route's task_done side
effect moved the task file into done/ under an agent still writing to
it. The store's update_plan is an unconditional UPDATE ... WHERE id = ?,
so nothing downstream re-checked. No concurrency is involved - the tool
surface refuses the identical call sequentially. Root cause is that the
route was written as a generic field-updater rather than as the
lifecycle transition it performs, so it inherited none of its siblings'
preconditions.

Add the precondition where the others enforce it, immediately after
loading the row and before any write. It is the approve route's guard
with one word changed, so both HTTP plan surfaces read identically, and
it precedes the write and the task_done invocation alike, so a refused
decline changes nothing. Only the declined transition is guarded, since
it is the only PATCH-reachable transition with a task-closing side
effect; a test pins that scope so it is not later mistaken for an
oversight.

Intended behaviour change: this PATCH now returns 409 where it returned
{"updated": true}. That old success was a lie, and the response shape
matches the approve route's existing 409.

The 409 was swallowed twice on the web side. The store logged it and set
no actionError; and handleDecline did not await, so it cleared the form
and the typed reason unconditionally, which read as success. Compounding
both, the only actionError block sat inside the revise form, so it could
never render on the decline path. updatePlan now returns a boolean and
sets actionError via the existing extractErrorMessage, handleDecline
awaits it and clears the form only on success, and the error block is
hoisted to a shared area serving both forms.

tests/test_plan_decline_precondition.py (new, 15 cases) covers the 409
for an implementing plan with the row and the task both asserted
untouched, the unchanged pending happy path, every non-pending status,
a surface-parity assertion that the tool handler and the route agree per
status, and a superseded scope test. approved is in that status list
although no writer produces it, because this same unvalidated route can
store it. Without the fix, on a clean git archive export of main plus
the new test file: 13 failed, 2 passed. With it: 15 passed. Full suite
goes 20 -> 7 failures with no new failure by sorted FAILED-name-set
comparison; the 7 residuals are pre-existing in both arms. A seven-mutant
matrix on the guard is fully killed with the unmutated control green at
both ends. ruff and tsc -b are clean and eslint reports 0 errors; web/
has no test runner, so the web changes are verified by typecheck, lint
and reading rather than an automated kill.

Not addressed here: the plan-status writers are still non-atomic
read-then-write pairs, so a concurrent approval can clobber a sibling
transition. That is a different invariant needing a compare-and-swap on
all four writers, and it is not independently fixable while approval's
own write is unconditional. Tracked separately.
@oranjeai

oranjeai commented Aug 3, 2026

Copy link
Copy Markdown
Contributor Author

Internal second-model review

2 review rounds, 12 findings adjudicated (8 agreed and fixed, 1 disagreed with evidence, 3 recorded and carried) - gate spend $18.28 over 6 runs

Before opening this PR I put it through two independent review rounds: a cold read of the resulting
code plus an independent model (codex) reviewing the diff against the PR contract. Round 1 bounced
the branch back for a real test-coverage gap; round 2 found three false figures in my own published
write-ups. The 8 lines of production Python survived both rounds unchanged.

# Finding Severity Verdict
1 approved missing from the non-pending status list, so the "every non-pending status" contract had a status no test exercised major ❌ AGREE - fixed
2 A code comment claimed the store "optimistically wrote the new status"; it awaits first, so that path is unreachable nit ❌ AGREE - fixed
3 "Only declined has a documented precondition" - the docs document one for four transitions nit ⚠️ AGREE - reworded
4 The deferred follow-up work was published as "tracked separately" while nothing tracked it major ❌ AGREE - follow-up filed
5 Stale full-suite figures in the PR body (18 -> 7 / 2946; measured 20 -> 7 / 2948) major ❌ AGREE - fixed
6 PR body said "All 6 killed"; the matrix is 7 mutants major ❌ AGREE - fixed
7 Validation comment claimed a mutant "survived (15 passed)" on a suite that had 13 cases major ❌ AGREE - fixed
8 "The approve route's guard with one word changed, so both read identically" - the condition also adds a scope conjunct nit ⚠️ AGREE - reworded
9 Comments restate the PR narrative; shorten them nit 💡 DISAGREE - see below
10 A superseded PATCH on an implementing plan still diverges from the tool surface nit 💡 Recorded - deliberate, pinned by a test
11 approvePlan still swallows its own 409 in the UI nit 💡 Recorded - pre-existing, carried to the follow-up
12 "All four sibling surfaces" - counted as refusal sites there are five nit 💡 Recorded - exact as intended, direction understates

Finding 1 is why approved is in the status list. Proven by execution rather than argument: on
the pre-fix tree a mutant that lets approved through survived the whole suite with zero failures,
because no case set that status; with approved parametrized the same mutant is killed by exactly
the two cases adding it creates. The status is unreachable from any writer in this repo but reachable
through this very route, since PlanUpdateRequest.status is an unvalidated str and there is no
status whitelist anywhere.

Finding 7 is the one I would most want a reviewer to know about, because no automated check could
have caught it: an independent model reviews the diff and the PR body, so a claim living only in a
validation comment is outside its review object by construction. The published figure said a mutant
survived "15 passed" on the five-status matrix. That matrix had 13 cases (1 + 1 + 5 + 5 + 1), and the
run's own log ends "13 passed", with its controls at 13 both ends. The error inflated the pre-fix
suite, making the gap this round closed look larger than measured - an error in the change's own
favour. Corrected on all three surfaces carrying it, and the legitimate 15s (the 15-case r1 suite)
were asserted preserved rather than swept along.

Finding 9, disagreed. Measured each cited site against this repo's own precedent instead of a
general concision preference. The 9-line test module docstring is the same length and structure as
its nearest sibling tests/test_plan_revise.py. The guard's comment is 2 lines, inside the 1-2 line
limit, and is shorter than the 3-line pre-existing comment 8 lines below it that this PR does not
touch; its second line states where the guard sits relative to the write and the task_done
invocation, which is what the correctness argument rests on and is not inferable from the code. The
two web comments each explain why a failure path deliberately keeps state rather than clearing it.
Total added prose is 8 comment lines in a +252 diff.

Findings 10 to 12 are real and deliberately not fixed here. Both are pre-existing and outside
this PR's contract, which is the declined transition. Both are recorded in the follow-up task
rather than dropped, including a note that the test pinning finding 10's current behaviour must
change when the general whitelist lands and is not a regression.

Rounds and cost: 2 review rounds, 6 gate runs, $18.28 total.

@oranjeai

oranjeai commented Aug 3, 2026

Copy link
Copy Markdown
Contributor Author

Pre-PR validation gate

a-i checklist
# Check Answer
a Deterministic repro? Yes, and race-free. A store-level probe against a plan that is implementing with a live impl_session_id prints SEQUENTIAL_DEFECT=True with zero concurrency; at route level the new test file fails 13 of 15 on a clean main export, every run.
b Root cause explained? The route was written as a generic field-updater (status: str = "" # decline) rather than as the lifecycle transition it performs, so it inherited none of the preconditions its four siblings have. db.update_plan is an unconditional UPDATE ... WHERE id = ?, so nothing downstream re-checks, and the declined branch then invokes task_done on the strength of a status it never validated.
c Fix matches root cause? Yes. The missing precondition is added at the layer where all four siblings enforce it, immediately after the row load. No widened bound, no guard at a downstream failure site, no defensive check masking an upstream bug.
d Test intent preserved / new tests added? No existing test weakened or removed (the diff touches no existing test file). One new module, 15 cases; two of them are explicit regression guards for the happy path and for the deliberate scope limit.
e Demonstrated in BOTH directions? Yes. Without the fix (clean git archive main export plus the new test file): 13 failed, 2 passed, 0 collection errors. With it: 15 passed. Both arms re-run against the final tree after this round's test and prose edits, not carried.
f General across CODE paths? Both decline surfaces enumerated: the tool handler already guards (verified, unchanged) and the HTTP PATCH gap is closed. grep '"declined"' confirms those are the only two writers; both call task_done, and the guard precedes the write and the invocation. The only caller of PATCH /api/plans is the web UI (client.ts -> planStore.ts -> PlanDetailPage.tsx), fixed in both of its error-swallowing halves.
g Generalizes across INPUTS? All six non-pending statuses are parametrized: the five the store's writers actually produce (implementing, declined, superseded, done, failed) plus approved, which no writer produces but this same unvalidated route can store, since PlanUpdateRequest.status is a plain str with no whitelist anywhere. Also pending for the happy path and a non-declined status through the same route. A status-string precondition has no type-wrapper or numeric-boundary dimension.
h Backward compatible? No schema change, migration, setting, renamed/removed function or new dependency. One intended, user-visible change: a declined PATCH against a non-pending plan now returns 409 instead of {"updated": true}. That success was a lie (it declined a running plan and closed its task), and the response shape is identical to the approve route's existing 409, which the same client already handles. Called out under its own heading in the PR body.
i Invariants and contracts preserved? The guard is the first statement after the 404 check, so on refusal nothing runs: no field assembled, no write issued, task_done never invoked. Asserted on the plan row (status, impl_session_id), on the task row (status, file_path) and on the filesystem (source file present, done/ copy absent). The happy path is byte-for-byte the previous behaviour, and db.update_plan is unmodified so its other six call sites are unaffected. The web contract change is typed (Promise<void> -> Promise<boolean>) and tsc -b passes over all callers.

Mutation matrix (each mutant asserts its edit applied exactly once and that the file still
parses; unmutated control green at both ends, so no kill is a harness artifact):

mutant verdict
guard deleted (the original defect) KILLED 13F/2P
guard after the update_plan write KILLED 11F/4P
guard after the task_done invocation KILLED 13F/2P
guard applied to all statuses KILLED 1F/14P (scope test)
returns 400 instead of 409 KILLED 13F/2P
predicate inverted (== "pending") KILLED 14F/1P
guard lets approved through (not in ("pending", "approved")) KILLED 2F/13P

The last mutant is why approved is in the status list. Against the five-status matrix this PR
first carried, it survived the entire suite (13 passed, zero failures - that matrix had 13
cases, not 15), because no case set that status; with approved parametrized it is killed by
exactly the two cases that adding it creates. A status the contract promises to refuse but no
test exercises is not covered by the contract's other rows.

One correction worth recording: the plan predicted the after-the-write and after-task_done mutants
would be caught by the state and task-closure assertions respectively. Measured, both are killed
first by the plan-status assertion, and because task_done cascades implementing plans to done,
no mutant can isolate the task-closure assertion on that test at all. A further mutant (precondition
suppresses the write and returns 409, but only after task_done has run) evaluated on the
superseded and declined parametrizations is killed with the task-closure assertion as the first
and only failing statement, which is what actually establishes those assertions are load-bearing.

web/ has no test runner, so the three web changes are verified by tsc -b, eslint and reading,
not by a mutant kill. Stating that rather than implying automated coverage.

@CLAassistant

Copy link
Copy Markdown

CLA assistant check
Thank you for your submission! We really appreciate it. Like many open source projects, we ask that you sign our Contributor License Agreement before we can accept your contribution.
You have signed the CLA already but the status is still pending? Let us recheck it.

@oranjeai

oranjeai commented Aug 4, 2026

Copy link
Copy Markdown
Contributor Author

Closing per @pufit's directive on #247: memU is being rewritten and sunset, and Nerve fixes
outside "critical performance problem" or "makes my work easier" are handled by the Nerve team.
This PR is a correctness fix in neither category, so it is closed unmerged. The analysis stays in
the description and comments if it is useful during the rewrite. No further action needed from me.

@oranjeai oranjeai closed this Aug 4, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants