research: don't advance to reviewing without an outcome + guard-before-write
ci / gates (push) Successful in 6s
ci / frontend (push) Successful in 36s
ci / rust (push) Failing after 1m56s
ci / e2e (push) Has been skipped
ci / publish (push) Has been skipped

Two bugs that combine to produce the 409 you get when clicking Approve:

1) notify_run_completed (topology_worker post-hook) was advancing
   topic status processing → reviewing whenever the last sibling run
   terminated — success OR fail. A failed run with 0 outcomes still
   pushed the topic to `reviewing`, the canvas rendered the "Request
   publish" affordance, and the reviewer clicked Approve on nothing.

   Fixed by adding an EXISTS(research_outcomes …) clause to the
   UPDATE. Topic stays in `processing` when no outcome exists; the
   loop's next iteration still has a chance to produce one.

2) decide_publish was calling
     research_publish_approvals::decide(approve=true)
   FIRST (which flips the row to `status='approved'`) and then
   running the "no outcome? 409" guard SECOND. On the 409 return,
   the DB was left half-flipped: approval says approved, topic still
   in reviewing, no outcome exists, and every future click to the
   same approval returns 409 on the "already decided" guard —
   leaving reviewers with no way forward.

   Fixed by moving the outcome-existence check BEFORE the decide()
   call. On 409 now nothing was written, so the reviewer can try
   again cleanly once an outcome is produced.

Also unstuck the current stuck row out-of-band (SQL UPDATE to reset
the approval to pending + topic to processing) so the user isn't
forced to delete the topic to escape the 409 loop.

sqlx dynamic query — the new EXISTS clause wasn't in the offline
cache so I switched notify_run_completed to plain `sqlx::query`.
This commit is contained in:
Omar Sobh
2026-07-11 08:44:34 -07:00
parent ee05037095
commit 7e0620fd08
2 changed files with 33 additions and 17 deletions
+13 -14
View File
@@ -972,6 +972,19 @@ async fn decide_publish(
if approval.status != "pending" {
return Err(ApiError::Conflict);
}
// Pre-flight the no-outcome guard BEFORE we flip the approval row.
// Previous ordering wrote `status = 'approved'` first, then 409'd on
// this check — leaving the DB in a half-flipped state (approval
// approved, topic still in reviewing, no outcome existed) which
// surfaces to reviewers as a permanent "already decided" 409 the
// next click.
if approve {
let outcome =
cm_db::repo::research_outcomes::latest(&state.pool, approval.topic_id).await?;
if outcome.is_none() {
return Err(ApiError::Conflict);
}
}
let notes_ref = notes.as_deref().map(str::trim).filter(|s| !s.is_empty());
let landed = cm_db::repo::research_publish_approvals::decide(
&state.pool,
@@ -988,20 +1001,6 @@ async fn decide_publish(
return Ok(StatusCode::NO_CONTENT);
}
if approve {
// Guard: you can't approve-to-publish a topic that has no
// outcome. Discovered on first prod run — the pipeline can
// silently reach `published` state with zero runs surfacing an
// outcome (LLM auth failure, network, etc.), leaving the
// download endpoint at a 404 with no user-facing warning.
//
// Refuse with 409 so the frontend can render "no artifact — run
// failed, check pipeline diagnostics" and the reviewer isn't
// fooled into thinking approval is a no-op.
let outcome =
cm_db::repo::research_outcomes::latest(&state.pool, approval.topic_id).await?;
if outcome.is_none() {
return Err(ApiError::Conflict);
}
// reviewing → publishing → published in one API call.
//
// Real async packaging isn't a thing yet — the artifact is the