Status: Accepted
Date: 2026-10-07
Deciders: Stijn Dejongh (repository owner).
Technical Story: #5446 (agent action implement pulled a work package out of review with exit 0 and a made-up reason); the forced-approval restamp narrowing is #5721. Related: #5340, #5668, ADR 2026-10-04-5.
Reader: a maintainer changing the status transition pipeline, a transition surface (move-task, agent status emit, orchestrator-api transition, agent action implement) or the review-state readers.
Context and Problem Statement
A work package in in_review or approved holds a reviewer's claim or verdict. Before this change, agent action implement <WP> --agent <other> force-moved such a package to in_progress for any agent, with the reason "Re-implementing after review feedback". Nothing in that event was a review outcome. The first fix guarded only the implement path, so move-task --force, agent status emit --force and the orchestrator-api transition verbs could still take a package out of review with no verdict and no explanation. move-task also minted a changes_requested review result for any in_review exit that was not an approval, so a forced --to blocked carried a verdict nobody gave.
Decision
- One rule, in the one pipeline.
status/transition_pipeline.prepare_transitionrefuses a transition when all of these hold: it is forced, its from-lane isin_revieworapproved, it carries noreview_result, and its reason is blank. The refusal is aTransitionErrorthat namesFORCE_NOTE_REQUIREDandFORCE_NOTE_HINT. The FSM already requires a non-blank reason for any force (WPState._check_force), so onagent status emitandorchestrator-apia bare force was already refused with a generic message; the leak wasmove-task, which filled the reason (Force move to <lane>) and, for anin_reviewexit, achanges_requestedverdict itself. The pipeline rule gives the exit its own named refusal and no longer lets a synthesized reason stand for a note. The pipeline stays git-free, so every surface that emits a transition gets the rule and renders the refusal in its own shape (exit 1 formove-taskandagent status emit, aTRANSITION_REJECTEDenvelope fororchestrator-api). - A verdict is the only other way out. A transition that records a
review_resultis the verdict and needs no note.move-taskmints a rejection result only for the rework edges (-> planned,-> in_progress), never for another exit, and it passes a bare--forceout of those lanes to the pipeline with no reason, so the pipeline sees what the operator actually supplied. - Migration actors are exempt. An actor starting with
migration:(MIGRATION_ACTOR_PREFIX, now defined instatusand imported by the consolidation attribution code) reconstructs state; it is not an operator act. - Implementer-of-record withdrawal stays on the
implementpath. Only the work package's implementer of record may withdraw an unclaimedfor_reviewsubmission, and that check needs the event log, the caller's actor and the claim holder, so it remains instatus/work_package_lifecycle.py(_review_lane_exit_reason).for_reviewis not a verdict-gated lane, so the pipeline rule does not apply to it. An unforcedimplementonin_revieworapprovedis still refused there as a claim conflict naming the reviewer, or with the rework route. - A cleared review-result slot is "no verdict on record". The reducer writes
review_result: nullwhen a package leavesin_reviewwith no verdict.ReviewResultLookup.cleared_without_verdictis true only for that rawNone; the readers treat it like an absent slot, so the next review approves normally. A malformed value (not an object, or an object that does not parse) keeps failing closed with a message that names a route that works. - A forced approval is a review only when a review backs it (#5721). A forced move into
approvedrestamps the consolidation bound only as an approved-reviewed attestation, an arbiter override, or a hop out ofin_reviewthat carries an approvedreview_resultafter an unforced move intoin_review. Two routes into that set stay open, both recorded operator acts: moving the package unforced intoin_reviewand then forcing it toapprovedwith a synthesized approved result, and a reject followed by a forced arbiter-override approval.
Considered Options
- Pipeline rule (chosen). One place, one refusal, every surface.
- Per-surface checks. Rejected: that is how
implementwas fixed first and the other three surfaces stayed open. - Refuse every forced exit from review. Rejected: an operator with a real reason (an unavailable reviewer, a damaged claim) needs the override; the note makes it auditable.
- Move the implementer-of-record check into the pipeline as well. Rejected: it needs implement-path context, and the pipeline reads no log.
Consequences
move-task <WP> --to <lane> --forceout ofin_revieworapprovedneeds--note;agent status emitneeds--reason;orchestrator-api transitionneeds--note. Without it nothing is written.- A forced
approved -> doneis also a forced exit fromapprovedand needs a note. - An unforced
move-taskout ofin_reviewto a lane other thanplanned,in_progressor an approval no longer gets a mintedchanges_requestedresult; the FSM refuses it for want of a review result, and the route is--force --note. - The rule judges the request, not the log: a reason of any non-blank text satisfies it. The note is evidence for the audit trail, not a check that the reason is true.
- Reversible only by a new decision; the cross-surface test in
tests/specify_cli/cli/commands/agent/test_approval_after_forced_review_exit.pyfails when any surface bypasses the pipeline rule.