fix(missions): the first real approval found two bugs the tests could not
Deploying Slice 5 and approving one roster in production broke it twice, in ways
528 green tests had nothing to say about.
**1. `jsonb_set` refuses a scalar.** A mission created through the API without a
`config` stores jsonb `null` — a scalar — and `jsonb_set` fails on it with
"cannot set path in scalar". The guard was `coalesce(config, '{}')`, which
protects against SQL NULL; this is a perfectly good JSON null of the wrong shape,
and coalesce passes it straight through. Every test wrote `'{}'::jsonb` because
that is what a test author types. Production types nothing at all.
**2. The approval was not atomic, and failing halfway is permanent.** The claim
and the mission write were two statements, claim first, so when the write failed
the proposal stood `approved` with nothing applied — and the partial unique index
then makes that state unrecoverable: no other proposal for that mission can ever
be approved. The mission ran solo with `team_engine` still NULL while its
proposal said otherwise.
`approve_and_apply` is now one transaction: claim, write, commit or roll back.
The type guard is `CASE WHEN jsonb_typeof(config) = 'object' THEN config ELSE
'{}'::jsonb END`, which answers the question that was actually being asked.
Both regressions are tested in the shape production had, and both NEGATIVE
CONTROLS were run rather than assumed:
- restore `coalesce` → `a_roster_applies_to_a_mission_whose_config_is_json_null`
FAILS with Postgres's own "cannot set path in scalar", the exact production
error.
- commit instead of roll back on a failed apply →
`a_failed_apply_leaves_the_proposal_undecided` FAILS with the proposal stuck
`approved`.
Worth stating plainly: the API returned 500 for that approval, so this was not
silent to the caller — but the row it left behind claimed the mission had a
roster it never received, and the mission then ran and delivered, which is the
shape that gets believed.
530 tests pass, clippy clean.
Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5
parent
aa470091aa
commit
75d09241fb
@@ -267,47 +267,33 @@ pub async fn decide(
|
|||||||
return Err(ApiError::BadRequest);
|
return Err(ApiError::BadRequest);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Claim the decision FIRST. The unique index allows one approved proposal
|
let graph = roster.graph().map_err(|e| {
|
||||||
// per mission, so this is what makes two approvals race safely: the loser
|
eprintln!("mission {id}: approved roster does not build a graph: {e}");
|
||||||
// updates nothing and never touches the mission.
|
ApiError::Internal
|
||||||
let claimed = cm_db::repo::mission_team_proposals::decide(
|
})?;
|
||||||
|
// Claiming the proposal and writing the mission are ONE transaction. Doing
|
||||||
|
// them as two statements left the first real approval in production marked
|
||||||
|
// `approved` with nothing written to the mission — and the partial unique
|
||||||
|
// index then makes that permanent, since no other proposal for that mission
|
||||||
|
// can ever be approved.
|
||||||
|
let claimed = cm_db::repo::mission_team_proposals::approve_and_apply(
|
||||||
&state.pool,
|
&state.pool,
|
||||||
pid,
|
pid,
|
||||||
|
id,
|
||||||
ws.as_uuid().to_owned(),
|
ws.as_uuid().to_owned(),
|
||||||
"approved",
|
&graph,
|
||||||
body.note.as_deref(),
|
body.note.as_deref(),
|
||||||
Some(user.user_id.as_uuid().to_owned()),
|
Some(user.user_id.as_uuid().to_owned()),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| {
|
.map_err(|e| {
|
||||||
eprintln!("mission {id}: could not approve proposal {pid}: {e}");
|
eprintln!("mission {id}: could not apply roster {pid}: {e}");
|
||||||
ApiError::Internal
|
ApiError::Internal
|
||||||
})?;
|
})?;
|
||||||
if !claimed {
|
if !claimed {
|
||||||
return Err(ApiError::BadRequest);
|
return Err(ApiError::BadRequest);
|
||||||
}
|
}
|
||||||
|
|
||||||
let graph = roster.graph().map_err(|e| {
|
|
||||||
eprintln!("mission {id}: approved roster does not build a graph: {e}");
|
|
||||||
ApiError::Internal
|
|
||||||
})?;
|
|
||||||
sqlx::query(
|
|
||||||
"UPDATE missions
|
|
||||||
SET config = jsonb_set(coalesce(config, '{}'::jsonb), '{roster}', $2::jsonb, true),
|
|
||||||
team_engine = 'composed',
|
|
||||||
updated_at = now()
|
|
||||||
WHERE id = $1 AND workspace_id = $3",
|
|
||||||
)
|
|
||||||
.bind(id)
|
|
||||||
.bind(&graph)
|
|
||||||
.bind(ws.as_uuid())
|
|
||||||
.execute(&state.pool)
|
|
||||||
.await
|
|
||||||
.map_err(|e| {
|
|
||||||
eprintln!("mission {id}: could not write the approved roster: {e}");
|
|
||||||
ApiError::Internal
|
|
||||||
})?;
|
|
||||||
|
|
||||||
eprintln!(
|
eprintln!(
|
||||||
"mission_roster: mission {id} now runs a {}-node composed graph from proposal {pid}",
|
"mission_roster: mission {id} now runs a {}-node composed graph from proposal {pid}",
|
||||||
roster.members.len()
|
roster.members.len()
|
||||||
|
|||||||
@@ -113,6 +113,74 @@ pub async fn get(
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Approve a proposal AND apply it to its mission, atomically.
|
||||||
|
///
|
||||||
|
/// One transaction, because the two halves are one decision. The first version
|
||||||
|
/// claimed the proposal and then wrote the mission in two statements, and
|
||||||
|
/// production found the hole on the first real approval: the write failed, the
|
||||||
|
/// claim stood, and the mission was left with no roster while its proposal said
|
||||||
|
/// `approved` — a state the partial unique index then makes permanent, since no
|
||||||
|
/// second proposal for that mission can ever be approved.
|
||||||
|
///
|
||||||
|
/// Returns false when the proposal was already decided (a double-clicked
|
||||||
|
/// approve), in which case nothing is written.
|
||||||
|
pub async fn approve_and_apply(
|
||||||
|
pool: &PgPool,
|
||||||
|
id: Uuid,
|
||||||
|
mission_id: Uuid,
|
||||||
|
workspace_id: Uuid,
|
||||||
|
graph: &Value,
|
||||||
|
note: Option<&str>,
|
||||||
|
decided_by: Option<Uuid>,
|
||||||
|
) -> Result<bool, DbError> {
|
||||||
|
let mut tx = pool.begin().await?;
|
||||||
|
|
||||||
|
let claimed = sqlx::query(
|
||||||
|
"UPDATE mission_team_proposals
|
||||||
|
SET status = 'approved', note = $3, decided_at = now(), decided_by = $4
|
||||||
|
WHERE id = $1 AND workspace_id = $2 AND status = 'proposed'",
|
||||||
|
)
|
||||||
|
.bind(id)
|
||||||
|
.bind(workspace_id)
|
||||||
|
.bind(note)
|
||||||
|
.bind(decided_by)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?
|
||||||
|
.rows_affected();
|
||||||
|
if claimed != 1 {
|
||||||
|
tx.rollback().await?;
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// `jsonb_set` REFUSES a scalar, and a mission created without a `config`
|
||||||
|
// stores jsonb `null` — a scalar. `coalesce` does not help: it guards SQL
|
||||||
|
// NULL, and this is a JSON null, which is a perfectly good non-NULL value of
|
||||||
|
// the wrong shape. Production hit this on the first real approval with
|
||||||
|
// "cannot set path in scalar".
|
||||||
|
let applied = sqlx::query(
|
||||||
|
"UPDATE missions
|
||||||
|
SET config = jsonb_set(
|
||||||
|
CASE WHEN jsonb_typeof(config) = 'object' THEN config ELSE '{}'::jsonb END,
|
||||||
|
'{roster}', $3::jsonb, true),
|
||||||
|
team_engine = 'composed',
|
||||||
|
updated_at = now()
|
||||||
|
WHERE id = $1 AND workspace_id = $2",
|
||||||
|
)
|
||||||
|
.bind(mission_id)
|
||||||
|
.bind(workspace_id)
|
||||||
|
.bind(graph)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?
|
||||||
|
.rows_affected();
|
||||||
|
if applied != 1 {
|
||||||
|
tx.rollback().await?;
|
||||||
|
return Err(DbError::NotFound);
|
||||||
|
}
|
||||||
|
|
||||||
|
tx.commit().await?;
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
|
||||||
/// Record a decision. Only a `proposed` row may be decided, so approving twice
|
/// Record a decision. Only a `proposed` row may be decided, so approving twice
|
||||||
/// — a double-click, a retried request — cannot re-apply a roster to a mission
|
/// — a double-click, a retried request — cannot re-apply a roster to a mission
|
||||||
/// that has since moved on. Returns whether this call was the one that decided.
|
/// that has since moved on. Returns whether this call was the one that decided.
|
||||||
|
|||||||
@@ -149,3 +149,92 @@ async fn a_proposal_belongs_to_its_workspace() {
|
|||||||
"another workspace must not be able to approve this roster"
|
"another workspace must not be able to approve this roster"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The bug production found on the FIRST real approval, in the exact shape it
|
||||||
|
/// had: a mission created through the API with no `config` stores jsonb `null`
|
||||||
|
/// — a scalar — and `jsonb_set` refuses a scalar with "cannot set path in
|
||||||
|
/// scalar". `coalesce` does not help, because that guards SQL NULL and this is a
|
||||||
|
/// perfectly good JSON null of the wrong shape.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_roster_applies_to_a_mission_whose_config_is_json_null() {
|
||||||
|
let pool = cm_testkit::test_pool().await;
|
||||||
|
let ws = workspace(&pool).await;
|
||||||
|
let m = mission(&pool, ws).await;
|
||||||
|
// Exactly what `POST /api/missions` stores when the body omits `config`.
|
||||||
|
sqlx::query("UPDATE missions SET config = 'null'::jsonb WHERE id = $1")
|
||||||
|
.bind(m)
|
||||||
|
.execute(&pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let id = Uuid::now_v7();
|
||||||
|
proposals::insert(&pool, id, m, ws.as_uuid().to_owned(), &roster(), "claude-opus-4-8")
|
||||||
|
.await
|
||||||
|
.expect("insert");
|
||||||
|
let graph = json!({"kind":"pipeline","nodes":[{"id":"n0","role":"implementer","attrs":{}}],"edges":[]});
|
||||||
|
|
||||||
|
let applied = proposals::approve_and_apply(
|
||||||
|
&pool,
|
||||||
|
id,
|
||||||
|
m,
|
||||||
|
ws.as_uuid().to_owned(),
|
||||||
|
&graph,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("apply");
|
||||||
|
assert!(applied);
|
||||||
|
|
||||||
|
let (engine, nodes): (Option<String>, Option<i32>) = sqlx::query_as(
|
||||||
|
"SELECT team_engine, jsonb_array_length(config->'roster'->'nodes') FROM missions WHERE id = $1",
|
||||||
|
)
|
||||||
|
.bind(m)
|
||||||
|
.fetch_one(&pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(engine.as_deref(), Some("composed"));
|
||||||
|
assert_eq!(nodes, Some(1), "the roster must actually be on the mission");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Claim and apply are one decision, so they must commit or fail together. A
|
||||||
|
/// proposal marked `approved` against a mission that never received the roster
|
||||||
|
/// is permanent: the partial unique index blocks every later approval, and the
|
||||||
|
/// mission runs solo while its proposal says otherwise.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_failed_apply_leaves_the_proposal_undecided() {
|
||||||
|
let pool = cm_testkit::test_pool().await;
|
||||||
|
let ws = workspace(&pool).await;
|
||||||
|
let m = mission(&pool, ws).await;
|
||||||
|
let id = Uuid::now_v7();
|
||||||
|
proposals::insert(&pool, id, m, ws.as_uuid().to_owned(), &roster(), "claude-opus-4-8")
|
||||||
|
.await
|
||||||
|
.expect("insert");
|
||||||
|
|
||||||
|
// A mission id that does not exist in this workspace: the apply half matches
|
||||||
|
// no row, which is the failure the transaction has to undo.
|
||||||
|
let err = proposals::approve_and_apply(
|
||||||
|
&pool,
|
||||||
|
id,
|
||||||
|
Uuid::now_v7(),
|
||||||
|
ws.as_uuid().to_owned(),
|
||||||
|
&json!({}),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert!(err.is_err(), "applying to a missing mission must fail: {err:?}");
|
||||||
|
|
||||||
|
let rows = proposals::list(&pool, m, ws.as_uuid().to_owned()).await.expect("list");
|
||||||
|
assert_eq!(
|
||||||
|
rows[0].status, "proposed",
|
||||||
|
"the claim must have been rolled back, or this proposal is stuck approved forever"
|
||||||
|
);
|
||||||
|
// And it can still be approved properly afterwards.
|
||||||
|
let graph = json!({"kind":"pipeline","nodes":[{"id":"n0","role":"implementer","attrs":{}}],"edges":[]});
|
||||||
|
assert!(
|
||||||
|
proposals::approve_and_apply(&pool, id, m, ws.as_uuid().to_owned(), &graph, None, None)
|
||||||
|
.await
|
||||||
|
.expect("apply")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user