Skip to content

workflow: rejecting a draft and replaying it with feedback works again (#870) - #877

Merged
Masterplanner25 merged 2 commits into
mainfrom
fix/870-reject-and-revise
Sep 24, 2026
Merged

Masterplanner25 merged 2 commits into
mainfrom
fix/870-reject-and-revise

Conversation

@Masterplanner25

Copy link
Copy Markdown
Owner

Closes #870.

The finding

#482 (5.5.0) refused resume_workflow(id, checkpoint, payload) on a waiting run,
on the stated grounds that "the rollback re-armed the wait and the payload was
silently discarded"
.

The payload was not discarded. Measured on 4.0.8 in a clean venv, where the
spelling worked, it reached workflow_resume_payload() in the replayed step:

[4.0.8]  checkpoint+payload -> status=waiting  draft={'fb': '{"feedback": "redo"}'}
[5.14.0] checkpoint+payload -> ok=False        "drop the checkpoint argument"

What it does not do is satisfy the wait. That is a different thing, and it is
exactly what a reviewer rejecting a draft wants: replay from before the draft
with the feedback, then park for approval again. So the refusal removed a working
pattern on a false premise — and the premise was in the error message and the
CHANGELOG, so a reader hit it twice.

The capability was never removed, only its spelling

The refusal's condition includes event_type is None. event_type is a
verification argument — "refuse unless the run is waiting on this event" —
and it suppresses the refusal only incidentally. Confirmed on main before
changing anything:

2. .nd builtin (refused)   : False | waiting_run_checkpoint_resume
3. runner + event_type     : waiting {'draft': {'fb': '{"feedback": "redo"}'}}
   -> replayed step saw the payload? yes
   -> did publish fire?              no

So the supported path was refused while the capability sat behind an accident.
BothPathsAgree now pins the two spellings to one answer so the supported one
cannot drift back onto it.

What changed, and what deliberately did not

checkpoint + payload is allowed. One condition: and resume_payload is None
added to the refusal.

resume_workflow(id, "checkpoint") with no payload is still refused, and
that is the part of #482 that earned its place — a rollback re-enters the waiting
step, which waits again, so nothing changes behind a healthy-looking result. Its
message now names both recipes instead of only the advancing one.

The neighbouring mistakes needed nothing: a checkpoint the run has not reached,
and a typo'd one, already fail with Checkpoint not found. Verified all five
shapes rather than assuming:

Call Now
pre-wait cp + payload replays, parks again, publish does not fire
pre-wait cp, no payload refused, names both recipes
post-wait cp + payload Checkpoint not found
typo'd cp + payload Checkpoint not found
payload alone advances, publish runs

Why this beats the workaround

The two-phase dance the reporting project found — satisfy the wait, then roll
back — takes two calls and runs every post-wait step during phase one, so a
side-effecting publish fires on a rejection unless each step gates on the
payload. The guide documents the workaround for 5.5.0–5.14.0 and says plainly
why it is a workaround.

The 5.5.0 record is annotated, not rewritten

Editing a released entry would leave the repo disagreeing with the published
notes. The entry stays as shipped with a note naming the wrong sentence and what
it cost — it is the sentence a future reader would use to decide whether the
refusal can be relaxed, which is the decision this PR just made.

Tests

tests/test_reject_and_revise.py (11) plus the rewritten #482 test. Three
per-decision neuters, restoring from saved byte copies:

BASELINE (fixed tree)                        14 passed
the 5.5.0 refusal, restored                  RED   7 failed
refusal message drops the reject recipe      RED   1 failed
refusal stops firing entirely                RED   3 failed
RESTORED                                     14 passed

test_checkpoint_with_payload_is_refused_naming_the_discard asserted the false
premise, so it is rewritten rather than kept — the same shape as #102's scan test
in #869, which pinned that defect too. Two of #482's three tests survive
unchanged
, which is the check that this narrowed the refusal rather than
gutting it.

Checks

  • test_reject_and_revise, test_waiting_run_checkpoint_resume,
    test_nodus_workflow_framework, test_parked_run_survives_age_bound,
    test_resume_rebuild, test_runner_results: 72 passed
  • ruff clean · mypy src/nodus/ clean — though it does not cover
    src/nodus_lang_workflow/
    , where this change lives, so CI is the only type
    check for it
  • nodus_gate --all green, all ten phases, before the push — BUG-869: the local store's age filter hides waiting runs from listing, migration, rehydration and the #174 warning #869's lesson,
    where I ran two phases, reasoned the rest were irrelevant, and CI failed on
    --flips

🤖 Generated with Claude Code

Masterplanner25 and others added 2 commits September 23, 2026 21:34
#870)

resume_workflow(id, "before_draft", {"feedback": "..."}) on a waiting run
replays from that checkpoint with the payload visible to
workflow_resume_payload(), and parks at the wait again. Post-wait steps do NOT
run, so a side-effecting publish cannot fire on a rejection.

#482 (5.5.0) refused it, on the stated grounds that "the rollback re-armed the
wait and the payload was silently discarded". The payload was not discarded.
Measured on 4.0.8, where the spelling worked, it reached the replayed step;
what it did not do was SATISFY THE WAIT, which is a different thing and is
exactly what a reviewer rejecting a draft wants.

The capability was never actually removed, only its spelling. The refusal's
condition includes `event_type is None`, so passing that VERIFICATION argument
to the Python runner has skipped the refusal the whole time -- an accident, not
a design, and relying on it was the state of the art. Confirmed on main before
changing anything: the event_type path replays with the payload visible,
publish does not fire, the run re-parks. BothPathsAgree pins the two spellings
to one answer so the supported one cannot drift back onto the accident.

The two-phase workaround people found instead is worse and the guide now says
so: two calls, and phase one runs every post-wait step, so each must gate its
own side effects on the payload.

resume_workflow(id, "checkpoint") with no payload is STILL refused, and that is
what #482 earned -- a rollback re-enters the waiting step, which waits again,
so nothing changes behind a healthy-looking result. Its message names both
recipes now rather than only the advancing one. The neighbouring mistakes were
already covered elsewhere and are unchanged: a checkpoint the run has not
reached and a typo'd one both fail with "Checkpoint not found".

test_checkpoint_with_payload_is_refused_naming_the_discard asserted the false
premise, so it is rewritten rather than kept -- the same shape as #102's scan
test in #869, which pinned that defect too.

The 5.5.0 changelog entry is ANNOTATED, not rewritten: it is what that release
shipped, and silently editing it would leave the repo disagreeing with the
published notes. The note says which sentence is wrong and why it mattered --
it is the sentence a future reader would use to decide whether the refusal can
be relaxed.

nodus_gate --all green, all ten phases, before the push this time (#869's
lesson).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
CI's `probes` job failed on `5.4.0: a checkpoint resume of a waiting run is
refused`. It is a source assertion, and one of its three assertions required
the literal string "discards the payload" to be PRESENT in
`resume_workflow` -- the sentence #870 removed because it is false. So the
premise was pinned in three places at once: the changelog, the error message,
and a release probe asserting the error message.

The probe now asserts what the refusal actually guarantees: still refuses, still
consults the persisted state, is scoped by `and resume_payload is None`, and no
longer claims the payload is discarded.

Added a BEHAVIOURAL probe beside it. A source assertion cannot tell you the
capability works, which is exactly how the false premise survived -- it was
asserted as a string rather than run. The new one parks a run, rejects with
feedback, checks the replayed step saw it and the run re-parked, then approves.

Both were falsified against the restored 5.5.0 refusal before being kept: the
source probe fails on the scoping assertion, the behavioural one on the reject
itself.

Two process notes, because both nearly cost a second red CI:

- `nodus_gate --all` does NOT run the probes. I ran the gate before pushing
  (#869's lesson) and still missed this, because the probes are a separate
  harness that CI runs as its own job. Run both.
- The probe count did not move when I added the probe -- 129/129. `main()`
  invokes probes from an explicit list, so a decorated function nobody calls is
  dead code that looks live. That is #794's shape (declared, printed, read by
  nothing). Adding the call took it to 130/130, and the moving count is the only
  thing that showed it.

Also: `@probe` swallows AssertionError into RESULTS, so calling a probe and
seeing no exception proves nothing. Read RESULTS.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@Masterplanner25
Masterplanner25 merged commit 10b3ea2 into main Sep 24, 2026
6 checks passed
@Masterplanner25
Masterplanner25 deleted the fix/870-reject-and-revise branch September 24, 2026 05:03
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.

BUG-870: #482 removed reject-and-revise with no replacement, and the CHANGELOG misstates what the old behaviour did

1 participant