research: reject-with-revision loop (R2)
ci / gates (push) Successful in 6s
ci / rust (push) Failing after 14s
ci / frontend (push) Successful in 28s
ci / e2e (push) Has been skipped
ci / publish (push) Has been skipped

Before: reviewer rejected a publish → audit log flipped, topic stayed
in reviewing, no way to feed the critique back into the run pipeline.
Reviewers with revision notes had to eat them or hand-message the
coordinator.

Now: reject accepts an optional `notes` field. When present:
- Persisted on the research_publish_approvals row (migration 0039).
- Topic flips `reviewing → standby` so the next `start_topic` is legal.
- `start_topic` reads the most recent rejected-approval notes for the
  topic and prepends "PRIOR REVIEW NOTES (address these in this
  revision):\n<notes>\n---" to the coordinator task.

Loop closes through the same run pipeline — no new spawn code path,
which means the reviewer's guidance flows through the same
topology_worker, run_events, outcome-writer chain and lands as a fresh
research_outcomes row (versioned, prior drafts preserved). No notes on
reject = legacy behavior (topic stays in reviewing, publish requests
still allowed).

Migration 0039 adds nullable `notes TEXT` to
research_publish_approvals. `decide()` gains a `notes: Option<&str>`
parameter (only one caller, updated inline). New
`latest_rejection_notes(pool, topic_id)` helper for start_topic.

Frontend:
- rejectPublish(id, notes?) now sends a JSON body when notes are
  provided.
- ResearchCanvas reject button opens an inline form with a textarea +
  Cancel/"Send back for revision" pair. Empty notes → plain reject.
- Button label switches: "Send back for revision" when notes present,
  "Reject without notes" when empty.

Follow-up:
- Notes shown in the review UI on the resulting draft so the next
  reviewer sees what changed.
- Multiple rejection rounds — currently only the LATEST rejection's
  notes surface. Accumulating history is a schema-only tweak.
This commit is contained in:
Omar Sobh
2026-07-09 15:56:16 -07:00
parent 4831033910
commit e3011ed025
5 changed files with 191 additions and 21 deletions
@@ -102,25 +102,56 @@ pub async fn list_pending(
/// Atomically flip a pending row to approved/rejected. Returns whether the
/// caller was the one who won the race — false when the row was already
/// decided (idempotent).
/// decided (idempotent). Optional `notes` are stashed on the row so a
/// subsequent `start_topic` can pick them up as revision guidance
/// (R2 — reject-with-revision).
pub async fn decide(
pool: &PgPool,
id: Uuid,
workspace_id: Uuid,
decided_by: Uuid,
approve: bool,
notes: Option<&str>,
) -> Result<bool, DbError> {
let new_status = if approve { "approved" } else { "rejected" };
let result = sqlx::query!(
// Dynamic sqlx::query so the new `notes` column doesn't need a fresh
// .sqlx offline cache entry — the value is bound at runtime.
let result = sqlx::query(
"UPDATE research_publish_approvals
SET status = $4, decided_by = $3, decided_at = now()
SET status = $4, decided_by = $3, decided_at = now(), notes = $5
WHERE id = $1 AND workspace_id = $2 AND status = 'pending'",
id,
workspace_id,
decided_by,
new_status,
)
.bind(id)
.bind(workspace_id)
.bind(decided_by)
.bind(new_status)
.bind(notes)
.execute(pool)
.await?;
Ok(result.rows_affected() > 0)
}
/// Most-recent rejected-approval notes for a topic, or None. Used by
/// `start_topic` to prepend a reviewer's revision guidance to the next
/// coordinator task. Only returns non-empty strings; a rejection with
/// no notes reads the same as no rejection at all.
pub async fn latest_rejection_notes(
pool: &PgPool,
topic_id: Uuid,
) -> Result<Option<String>, DbError> {
use sqlx::Row;
let row: Option<sqlx::postgres::PgRow> = sqlx::query(
"SELECT notes
FROM research_publish_approvals
WHERE topic_id = $1 AND status = 'rejected' AND notes IS NOT NULL
ORDER BY decided_at DESC NULLS LAST
LIMIT 1",
)
.bind(topic_id)
.fetch_optional(pool)
.await?;
Ok(row
.and_then(|r| r.try_get::<Option<String>, _>("notes").ok().flatten())
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty()))
}