research: publish approval gate + explicit state transitions
ci / gates (push) Successful in 6s
ci / frontend (push) Successful in 24s
ci / rust (push) Successful in 3m44s
ci / publish (push) Successful in 2m14s
ci / e2e (push) Failing after 29m59s

Fourth commit of the Research + Loops arc. Completes the state machine
for research topics with the publish approval gate the spec asked for.

Migration 0032 — research_publish_approvals
  Dedicated small table (id, workspace_id, topic_id, requested_by,
  status, decided_by/at, created_at). Keeping it separate from the
  existing `approvals` table (0001) because that one is tightly coupled
  to gated tool calls inside an agent run — session_key + run_id +
  action_type + category + payload + preview + requested_by_agent, all
  NOT NULL. Forcing those nullable would ripple through cm_safety;
  cleaner to give publish approvals their own two-transition state
  machine.

New endpoints
  POST /api/research/:id/submit-review               processing → reviewing
                                                     (v1 caller-driven; the
                                                     orchestrator hook comes
                                                     when we wire actual runs)
  POST /api/research/:id/request-publish             creates a pending
                                                     approval. Rejects with
                                                     409 if the topic already
                                                     has one open.
  GET  /api/research/publish-approvals               list workspace's pending
  POST /api/research/publish-approvals/:id/approve   flips approval to
                                                     approved + transitions
                                                     the topic
                                                     reviewing → publishing
                                                     (which stamps
                                                     published_at)
  POST /api/research/publish-approvals/:id/reject    stays in reviewing; new
                                                     requests allowed

The approve/reject write is an atomic UPDATE ... WHERE status = 'pending';
the decide() repo function returns whether the caller won the race so
concurrent double-approves collapse to a single topic transition.

State machine after this commit:
  standby ─POST /start─▶ processing ─POST /submit-review─▶ reviewing
    ─POST /request-publish + approve─▶ publishing ─(future: artifact
    assembly)─▶ published
This commit is contained in:
Omar Sobh
2026-07-06 06:21:29 -07:00
parent 4b48c521eb
commit 973eeb272e
10 changed files with 570 additions and 12 deletions
@@ -0,0 +1,17 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO research_publish_approvals\n (id, workspace_id, topic_id, requested_by, status)\n VALUES ($1, $2, $3, $4, 'pending')",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid",
"Uuid",
"Uuid",
"Uuid"
]
},
"nullable": []
},
"hash": "208b41dc1f8cb9f6ff46ce6a01d15c2ddbf604d6a4062f95b1eb99720c244ff2"
}
@@ -0,0 +1,17 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE research_publish_approvals\n SET status = $4, decided_by = $3, decided_at = now()\n WHERE id = $1 AND workspace_id = $2 AND status = 'pending'",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid",
"Uuid",
"Uuid",
"Text"
]
},
"nullable": []
},
"hash": "40d90c412b44009003ebc692b143382bd157663ed5f2481f08d7abfcc5129b27"
}
@@ -0,0 +1,64 @@
{
"db_name": "PostgreSQL",
"query": "SELECT id, workspace_id, topic_id, requested_by, status,\n decided_by, decided_at, created_at\n FROM research_publish_approvals\n WHERE topic_id = $1 AND status = 'pending'\n LIMIT 1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Uuid"
},
{
"ordinal": 1,
"name": "workspace_id",
"type_info": "Uuid"
},
{
"ordinal": 2,
"name": "topic_id",
"type_info": "Uuid"
},
{
"ordinal": 3,
"name": "requested_by",
"type_info": "Uuid"
},
{
"ordinal": 4,
"name": "status",
"type_info": "Text"
},
{
"ordinal": 5,
"name": "decided_by",
"type_info": "Uuid"
},
{
"ordinal": 6,
"name": "decided_at",
"type_info": "Timestamptz"
},
{
"ordinal": 7,
"name": "created_at",
"type_info": "Timestamptz"
}
],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": [
false,
false,
false,
false,
false,
true,
true,
false
]
},
"hash": "989cc49438587f6d49c0e78b716b5001637c064c9128bd9cef639536c4450aeb"
}
@@ -0,0 +1,65 @@
{
"db_name": "PostgreSQL",
"query": "SELECT id, workspace_id, topic_id, requested_by, status,\n decided_by, decided_at, created_at\n FROM research_publish_approvals\n WHERE id = $1 AND workspace_id = $2",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Uuid"
},
{
"ordinal": 1,
"name": "workspace_id",
"type_info": "Uuid"
},
{
"ordinal": 2,
"name": "topic_id",
"type_info": "Uuid"
},
{
"ordinal": 3,
"name": "requested_by",
"type_info": "Uuid"
},
{
"ordinal": 4,
"name": "status",
"type_info": "Text"
},
{
"ordinal": 5,
"name": "decided_by",
"type_info": "Uuid"
},
{
"ordinal": 6,
"name": "decided_at",
"type_info": "Timestamptz"
},
{
"ordinal": 7,
"name": "created_at",
"type_info": "Timestamptz"
}
],
"parameters": {
"Left": [
"Uuid",
"Uuid"
]
},
"nullable": [
false,
false,
false,
false,
false,
true,
true,
false
]
},
"hash": "e600913908298405fd10dbd7f70d9fd72406f06d5fe165c5e8abfbd5e0c29b19"
}
@@ -0,0 +1,64 @@
{
"db_name": "PostgreSQL",
"query": "SELECT id, workspace_id, topic_id, requested_by, status,\n decided_by, decided_at, created_at\n FROM research_publish_approvals\n WHERE workspace_id = $1 AND status = 'pending'\n ORDER BY created_at DESC",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Uuid"
},
{
"ordinal": 1,
"name": "workspace_id",
"type_info": "Uuid"
},
{
"ordinal": 2,
"name": "topic_id",
"type_info": "Uuid"
},
{
"ordinal": 3,
"name": "requested_by",
"type_info": "Uuid"
},
{
"ordinal": 4,
"name": "status",
"type_info": "Text"
},
{
"ordinal": 5,
"name": "decided_by",
"type_info": "Uuid"
},
{
"ordinal": 6,
"name": "decided_at",
"type_info": "Timestamptz"
},
{
"ordinal": 7,
"name": "created_at",
"type_info": "Timestamptz"
}
],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": [
false,
false,
false,
false,
false,
true,
true,
false
]
},
"hash": "ee97028bde490c0b2ac1fc5746d8ecd3079a5f61875925b1153da6bd6b84bfb1"
}
+20
View File
@@ -399,6 +399,26 @@ pub fn router(state: AppState) -> Router {
"/api/research/{id}/start", "/api/research/{id}/start",
post(routes::research::start_topic), post(routes::research::start_topic),
) )
.route(
"/api/research/{id}/submit-review",
post(routes::research::submit_review),
)
.route(
"/api/research/{id}/request-publish",
post(routes::research::request_publish),
)
.route(
"/api/research/publish-approvals",
get(routes::research::list_pending_publish),
)
.route(
"/api/research/publish-approvals/{id}/approve",
post(routes::research::approve_publish),
)
.route(
"/api/research/publish-approvals/{id}/reject",
post(routes::research::reject_publish),
)
.route( .route(
"/api/research/wizard/refine", "/api/research/wizard/refine",
post(routes::research::refine_wizard), post(routes::research::refine_wizard),
+148 -5
View File
@@ -1,15 +1,20 @@
//! Research topic endpoints — the container CRUD + the wizard's one-shot LLM //! Research topic endpoints — CRUD, state-machine transitions, the publish
//! refine call. The state machine's advancing transitions (processing → //! approval gate, and the wizard's one-shot LLM refine call. Reads for the
//! reviewing, reviewing → publishing) land in later commits alongside the //! publish gate share this module because they're semantically the topic's
//! orchestrator + approval-gate wiring. What ships here: //! terminal action.
//! //!
//! GET /api/research list workspace's topics //! GET /api/research list workspace's topics
//! POST /api/research create (accepts wizard output) //! POST /api/research create (accepts wizard output)
//! GET /api/research/:id detail (topic + attached agents) //! GET /api/research/:id detail (topic + agents)
//! PATCH /api/research/:id update non-status fields //! PATCH /api/research/:id update non-status fields
//! POST /api/research/:id/agents attach agent (idempotent) //! POST /api/research/:id/agents attach agent (idempotent)
//! DELETE /api/research/:id/agents/:agent detach //! DELETE /api/research/:id/agents/:agent detach
//! POST /api/research/:id/start standby → processing //! POST /api/research/:id/start standby → processing
//! POST /api/research/:id/submit-review processing → reviewing
//! POST /api/research/:id/request-publish create pending publish approval
//! GET /api/research/publish-approvals list workspace's pending approvals
//! POST /api/research/publish-approvals/:id/approve reviewing → publishing
//! POST /api/research/publish-approvals/:id/reject stays in reviewing
//! POST /api/research/wizard/refine one-shot LLM refine helper //! POST /api/research/wizard/refine one-shot LLM refine helper
use axum::extract::{Path, State}; use axum::extract::{Path, State};
@@ -238,6 +243,144 @@ pub async fn start_topic(
Ok(StatusCode::NO_CONTENT) Ok(StatusCode::NO_CONTENT)
} }
/// `POST /api/research/:id/submit-review` — flips status `processing → reviewing`.
/// v1 is caller-driven: the UI hits this when the human is happy with the
/// runs' output. A later commit hooks this from the orchestrator on the
/// last topology_run's completion.
pub async fn submit_review(
State(state): State<AppState>,
Authed(user): Authed,
Path(id): Path<Uuid>,
) -> Result<StatusCode, ApiError> {
let topic = cm_db::repo::research_topics::get(&state.pool, id, user.workspace_id.as_uuid())
.await?
.ok_or(ApiError::NotFound)?;
if topic.status != "processing" {
return Err(ApiError::Conflict);
}
cm_db::repo::research_topics::set_status(
&state.pool,
id,
user.workspace_id.as_uuid(),
"reviewing",
)
.await?;
Ok(StatusCode::NO_CONTENT)
}
// ── publish approval gate ─────────────────────────────────────────────────
#[derive(serde::Serialize)]
pub struct PublishApprovalCreated {
pub approval_id: Uuid,
}
/// `POST /api/research/:id/request-publish` — a workspace member requests a
/// publish. Topic must be in `reviewing`. Rejects with 409 if there's
/// already a pending request (one at a time). The topic stays in `reviewing`
/// until an approver decides.
pub async fn request_publish(
State(state): State<AppState>,
Authed(user): Authed,
Path(id): Path<Uuid>,
) -> Result<(StatusCode, Json<PublishApprovalCreated>), ApiError> {
let topic = cm_db::repo::research_topics::get(&state.pool, id, user.workspace_id.as_uuid())
.await?
.ok_or(ApiError::NotFound)?;
if topic.status != "reviewing" {
return Err(ApiError::Conflict);
}
if cm_db::repo::research_publish_approvals::pending_for_topic(&state.pool, id)
.await?
.is_some()
{
return Err(ApiError::Conflict);
}
let approval_id = cm_db::repo::research_publish_approvals::create(
&state.pool,
user.workspace_id.as_uuid(),
id,
user.user_id.as_uuid(),
)
.await?;
Ok((
StatusCode::CREATED,
Json(PublishApprovalCreated { approval_id }),
))
}
pub async fn list_pending_publish(
State(state): State<AppState>,
Authed(user): Authed,
) -> Result<Json<Vec<cm_db::repo::research_publish_approvals::PublishApproval>>, ApiError> {
Ok(Json(
cm_db::repo::research_publish_approvals::list_pending(
&state.pool,
user.workspace_id.as_uuid(),
)
.await?,
))
}
/// Shared body of approve + reject. On approve, transition the topic
/// `reviewing → publishing` (and set published_at via set_status). On
/// reject, topic stays put; new requests are allowed.
async fn decide_publish(
state: AppState,
user: cm_auth::AuthedUser,
id: Uuid,
approve: bool,
) -> Result<StatusCode, ApiError> {
let approval =
cm_db::repo::research_publish_approvals::get(&state.pool, id, user.workspace_id.as_uuid())
.await?
.ok_or(ApiError::NotFound)?;
if approval.status != "pending" {
return Err(ApiError::Conflict);
}
let landed = cm_db::repo::research_publish_approvals::decide(
&state.pool,
id,
user.workspace_id.as_uuid(),
user.user_id.as_uuid(),
approve,
)
.await?;
// Someone else won the race — treat as a no-op success; the topic
// transition already happened (or didn't) with their decision.
if !landed {
return Ok(StatusCode::NO_CONTENT);
}
if approve {
// reviewing → publishing (set_status also stamps published_at when
// landing in `publishing` for the first time).
cm_db::repo::research_topics::set_status(
&state.pool,
approval.topic_id,
user.workspace_id.as_uuid(),
"publishing",
)
.await?;
}
Ok(StatusCode::NO_CONTENT)
}
pub async fn approve_publish(
State(state): State<AppState>,
Authed(user): Authed,
Path(id): Path<Uuid>,
) -> Result<StatusCode, ApiError> {
decide_publish(state, user, id, true).await
}
pub async fn reject_publish(
State(state): State<AppState>,
Authed(user): Authed,
Path(id): Path<Uuid>,
) -> Result<StatusCode, ApiError> {
decide_publish(state, user, id, false).await
}
// ── wizard refine ────────────────────────────────────────────────────────── // ── wizard refine ──────────────────────────────────────────────────────────
#[derive(Deserialize)] #[derive(Deserialize)]
+1
View File
@@ -16,6 +16,7 @@ pub mod node_tools;
pub mod nodes; pub mod nodes;
pub mod orgs; pub mod orgs;
pub mod outbox; pub mod outbox;
pub mod research_publish_approvals;
pub mod research_topics; pub mod research_topics;
pub mod routine_runs; pub mod routine_runs;
pub mod routines; pub mod routines;
@@ -0,0 +1,126 @@
//! Publish approval gate for research topics — see 0032 migration header.
//! Small table with a small state machine (pending → approved | rejected).
//! One pending row per topic at a time; enforced at the route layer by
//! looking up `pending_for_topic` before create.
use serde::{Deserialize, Serialize};
use sqlx::PgPool;
use time::OffsetDateTime;
use uuid::Uuid;
use crate::DbError;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PublishApproval {
pub id: Uuid,
pub workspace_id: Uuid,
pub topic_id: Uuid,
pub requested_by: Uuid,
pub status: String,
pub decided_by: Option<Uuid>,
pub decided_at: Option<OffsetDateTime>,
pub created_at: OffsetDateTime,
}
pub async fn create(
pool: &PgPool,
workspace_id: Uuid,
topic_id: Uuid,
requested_by: Uuid,
) -> Result<Uuid, DbError> {
let id = Uuid::now_v7();
sqlx::query!(
"INSERT INTO research_publish_approvals
(id, workspace_id, topic_id, requested_by, status)
VALUES ($1, $2, $3, $4, 'pending')",
id,
workspace_id,
topic_id,
requested_by,
)
.execute(pool)
.await?;
Ok(id)
}
/// Pending approval for a topic, if any. The route layer uses this to
/// short-circuit before writing a duplicate request.
pub async fn pending_for_topic(
pool: &PgPool,
topic_id: Uuid,
) -> Result<Option<PublishApproval>, DbError> {
let row = sqlx::query_as!(
PublishApproval,
"SELECT id, workspace_id, topic_id, requested_by, status,
decided_by, decided_at, created_at
FROM research_publish_approvals
WHERE topic_id = $1 AND status = 'pending'
LIMIT 1",
topic_id,
)
.fetch_optional(pool)
.await?;
Ok(row)
}
pub async fn get(
pool: &PgPool,
id: Uuid,
workspace_id: Uuid,
) -> Result<Option<PublishApproval>, DbError> {
let row = sqlx::query_as!(
PublishApproval,
"SELECT id, workspace_id, topic_id, requested_by, status,
decided_by, decided_at, created_at
FROM research_publish_approvals
WHERE id = $1 AND workspace_id = $2",
id,
workspace_id,
)
.fetch_optional(pool)
.await?;
Ok(row)
}
pub async fn list_pending(
pool: &PgPool,
workspace_id: Uuid,
) -> Result<Vec<PublishApproval>, DbError> {
let rows = sqlx::query_as!(
PublishApproval,
"SELECT id, workspace_id, topic_id, requested_by, status,
decided_by, decided_at, created_at
FROM research_publish_approvals
WHERE workspace_id = $1 AND status = 'pending'
ORDER BY created_at DESC",
workspace_id,
)
.fetch_all(pool)
.await?;
Ok(rows)
}
/// 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).
pub async fn decide(
pool: &PgPool,
id: Uuid,
workspace_id: Uuid,
decided_by: Uuid,
approve: bool,
) -> Result<bool, DbError> {
let new_status = if approve { "approved" } else { "rejected" };
let result = sqlx::query!(
"UPDATE research_publish_approvals
SET status = $4, decided_by = $3, decided_at = now()
WHERE id = $1 AND workspace_id = $2 AND status = 'pending'",
id,
workspace_id,
decided_by,
new_status,
)
.execute(pool)
.await?;
Ok(result.rows_affected() > 0)
}
@@ -0,0 +1,41 @@
-- Publish approval gate for research topics. Distinct from the existing
-- `approvals` table (0001), which is tightly coupled to gated tool calls
-- inside an agent run (session_key + run_id + action_type + category +
-- payload + preview + requested_by_agent, all NOT NULL). None of those apply
-- to a publish-of-a-static-topic gate; forcing them nullable would ripple
-- through cm_safety, so we give publish approvals their own small table.
--
-- Lifecycle:
-- 1. Topic reaches `reviewing` (either the orchestrator flipped it after
-- the last agent run completed, or the user hit /submit-review).
-- 2. A workspace member calls /api/research/:id/request-publish, which
-- writes a row here with status='pending'.
-- 3. Another workspace member (any user in the workspace; per-role gating
-- can layer on later) approves or rejects.
-- 4. On the first approval, the topic transitions `reviewing → publishing`
-- and (after any artifact assembly) `publishing → published`.
-- 5. On a reject, the topic stays in `reviewing` and a new request is
-- allowed. Old rejected rows are audit history.
CREATE TABLE research_publish_approvals (
id UUID PRIMARY KEY,
workspace_id UUID NOT NULL REFERENCES workspaces (id) ON DELETE CASCADE,
topic_id UUID NOT NULL REFERENCES research_topics (id) ON DELETE CASCADE,
requested_by UUID NOT NULL REFERENCES users (id) ON DELETE RESTRICT,
status TEXT NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending', 'approved', 'rejected')),
decided_by UUID REFERENCES users (id) ON DELETE RESTRICT,
decided_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Sidebar "pending approvals" list query.
CREATE INDEX research_publish_approvals_pending_idx
ON research_publish_approvals (workspace_id, created_at DESC)
WHERE status = 'pending';
-- Look up "does this topic already have a pending approval?" before letting
-- a caller create a second one.
CREATE INDEX research_publish_approvals_topic_pending_idx
ON research_publish_approvals (topic_id)
WHERE status = 'pending';